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
@@ -71,6 +71,11 @@ QUICK_COMMANDS: tuple[tuple[str, tuple[str, ...]], ...] = (
("Property editor specs", ("verify_property_editor_specs.py",)),
("Property table editor UI", ("verify_property_card_editor_ui.py",)),
("Parametric component export", ("verify_parametric_component_export.py",)),
("SCDM backend discovery", ("verify_scdm_backend.py",)),
("SCDM runtime status", ("verify_scdm_status.py",)),
("SCDM probe pipeline", ("verify_scdm_probe_pipeline.py",)),
("SCDM edit runner", ("verify_scdm_edit_runner.py",)),
("SCDM result validator", ("verify_scdm_result_validator.py",)),
("Analysis Situs hole bridge", ("verify_asitus_hole_bridge.py",)),
("ICEPAK cylindrical same-domain hole", ("verify_icepak_cylindrical_region_selection.py",)),
("Feature recognition priority", ("verify_feature_recognition_summary.py",)),
+79 -1
View File
@@ -19,13 +19,19 @@ if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.model import StepModel
from step_editor.records import OperationRecord
from step_editor.window_actions import WindowActionMixin
from step_editor.window_state import WindowStateMixin
class _WindowActionProbe(WindowActionMixin):
pass
class _WindowStateProbe(WindowStateMixin):
pass
def _wire_count(face) -> int:
count = 0
explorer = TopExp_Explorer(face, TopAbs_WIRE)
@@ -145,6 +151,9 @@ def main() -> int:
raise SystemExit(f"large stepped cap should be recognized as stepped cap: {plan}")
if plan.get("cylindrical_cap_extension_method") != "local-shell-rebuild":
raise SystemExit(f"large stepped cap should use local shell rebuild: {plan}")
stepped_isolation = _WindowActionProbe()._isolation_for_plan(plan, "push_pull_face", [face_id, 89.0])
if not stepped_isolation or stepped_isolation.get("reason") != "large-model-smooth-ui-isolated-occ-edit":
raise SystemExit(f"large stepped cap should use the smooth UI background process: {stepped_isolation}")
started = time.perf_counter()
result = model.push_pull_face(face_id, 89.0)
@@ -233,7 +242,13 @@ def main() -> int:
multi_face_id = _large_multi_boundary_cap_face(multi_model)
multi_logical_id = multi_model.face_region_logical_id(multi_face_id)
multi_before_topology = _face_topology_counts(multi_model, multi_face_id)
started = time.perf_counter()
multi_plan = multi_model.push_pull_plan(multi_face_id, 34.5)
multi_plan_elapsed = time.perf_counter() - started
if multi_plan_elapsed > 5.0:
raise SystemExit(
f"multi-boundary cap push/pull plan should be quick: {multi_plan_elapsed:.3f}s; plan={multi_plan}"
)
if abs(float(multi_plan.get("current_plane_position") or 0.0) - 57.5) > 1e-9:
raise SystemExit(f"multi-boundary cap should start at 57.5: {multi_plan}")
if abs(float(multi_plan.get("target_plane_position") or 0.0) - 92.0) > 1e-9:
@@ -242,6 +257,22 @@ def main() -> int:
raise SystemExit(f"multi-boundary cap should be recognized as planar cap: {multi_plan}")
if multi_plan.get("planar_cap_extension_method") != "boundary-shell-rebuild":
raise SystemExit(f"multi-boundary cap should use boundary shell rebuild: {multi_plan}")
multi_isolation = _WindowActionProbe()._isolation_for_plan(multi_plan, "push_pull_face", [multi_face_id, 34.5])
if not multi_isolation or multi_isolation.get("reason") != "large-model-smooth-ui-isolated-occ-edit":
raise SystemExit(f"multi-boundary cap should use the smooth UI background process: {multi_isolation}")
ui_probe = _WindowActionProbe()
ui_probe.model = multi_model
ui_probe.current_info_values = multi_model.quick_face_info(multi_face_id)
ui_plan = ui_probe._push_pull_plan_for_action(multi_face_id, 34.5)
if bool(ui_plan.get("ui_deferred_model_plan")):
raise SystemExit(f"large multi-boundary UI plan should use the fast local plan now: {ui_plan}")
if ui_plan.get("planar_cap_extension_method") != "boundary-shell-rebuild":
raise SystemExit(f"large multi-boundary UI plan should use boundary-shell rebuild: {ui_plan}")
ui_isolation = ui_probe._isolation_for_plan(ui_plan, "push_pull_face", [multi_face_id, 34.5])
if not ui_isolation or ui_isolation.get("reason") != "large-model-smooth-ui-isolated-occ-edit":
raise SystemExit(f"large multi-boundary UI plan should use the smooth UI background process: {ui_isolation}")
if ui_probe._edit_preflight_blocker({"parameters": ui_plan}) is not None:
raise SystemExit(f"large multi-boundary UI plan should not be blocked before editing: {ui_plan}")
started = time.perf_counter()
multi_inward_plan = multi_model.push_pull_plan(multi_face_id, -1.0)
multi_inward_elapsed = time.perf_counter() - started
@@ -346,6 +377,53 @@ def main() -> int:
multi_after_topology,
min_inner_wires=5,
)
locator_probe = _WindowStateProbe()
locator_probe.model = multi_model
locator_record = OperationRecord(
summary="test",
detail="test",
operation_name="拉伸/切除平面",
target=f"Face {multi_face_id}",
parameters={
"part_id": multi_plan.get("part_id"),
"solid_id": multi_plan.get("solid_id"),
"surface": "plane",
"outward_direction": multi_plan.get("outward_direction") or multi_plan.get("plane_direction"),
"target_plane_position": multi_plan.get("target_plane_position"),
"bbox_diagonal": multi_plan.get("bbox_diagonal"),
},
result_message=multi_result,
target_kind="face",
target_id=multi_face_id,
target_logical_id=multi_logical_id,
)
started = time.perf_counter()
resolved_after_edit = locator_probe._resolve_record_face_id(locator_record)
locator_elapsed = time.perf_counter() - started
if resolved_after_edit != multi_retained_ids[0] or locator_elapsed > 0.5:
raise SystemExit(
f"large multi-boundary operation history locator should use the fast result Face: "
f"resolved={resolved_after_edit}, expected={multi_retained_ids[0]}, elapsed={locator_elapsed:.3f}s"
)
no_hint_record = OperationRecord(
summary="test",
detail="test",
operation_name="拉伸/切除平面",
target=f"Face {multi_face_id}",
parameters=locator_record.parameters,
result_message="Planar face push/pull completed without result face hint.",
target_kind="face",
target_id=multi_face_id,
target_logical_id=multi_logical_id,
)
started = time.perf_counter()
fallback_after_edit = locator_probe._record_plane_position_face_id(no_hint_record)
fallback_elapsed = time.perf_counter() - started
if fallback_after_edit != multi_retained_ids[0] or fallback_elapsed > 1.0:
raise SystemExit(
f"large multi-boundary fallback locator should use lightweight plane positions: "
f"resolved={fallback_after_edit}, expected={multi_retained_ids[0]}, elapsed={fallback_elapsed:.3f}s"
)
with tempfile.TemporaryDirectory(prefix="verify_large_multi_boundary_cap_isolated_") as temp_dir:
temp_root = Path(temp_dir)
@@ -442,7 +520,7 @@ def main() -> int:
)
print(
"large multi-boundary cap push/pull ok: "
f"face_id={multi_face_id}, elapsed={multi_elapsed:.3f}s, "
f"face_id={multi_face_id}, plan_elapsed={multi_plan_elapsed:.3f}s, elapsed={multi_elapsed:.3f}s, "
f"isolated_elapsed={multi_isolated_elapsed:.3f}s, "
f"topology_before={multi_before_topology}, topology_after={multi_after_topology}, result={multi_result}"
)
+544 -6
View File
@@ -4,8 +4,10 @@ import json
import math
import os
from pathlib import Path
import re
import sys
import tempfile
from types import SimpleNamespace
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
@@ -80,11 +82,17 @@ class _PropertyTableProbe(QWidget, WindowStateMixin):
self.step_path = PROJECT_ROOT / "assets" / "models" / "probe.step"
self.current_info_values = self._plane_info()
self.executed_property_actions: list[tuple[str, str]] = []
self.scdm_backend_status = None
self.scdm_feature_cache = None
self.scdm_feature_cache_state = "empty"
self.scdm_feature_cache_message = ""
self.scdm_edit_runner_ready = {"face.offset", "hole.diameter", "hole.position", "slot.position", "boss.position"}
layout = QVBoxLayout(self)
self.object_edit_box = self
self.property_table = QTableWidget(0, len(PROPERTY_TABLE_HEADERS))
self.property_table.setHorizontalHeaderLabels(list(PROPERTY_TABLE_HEADERS))
self.property_table.itemSelectionChanged.connect(lambda: self._update_property_apply_state())
layout.addWidget(self.property_table)
self.property_card_scroll = QScrollArea()
@@ -100,7 +108,9 @@ class _PropertyTableProbe(QWidget, WindowStateMixin):
self.property_command_bar = QFrame()
self.property_command_layout = QHBoxLayout(self.property_command_bar)
self.property_command_help_label = QLabel()
self.current_capability_headline = QLabel()
self.current_capability_button = QPushButton()
self.scdm_backend_status_label = QLabel()
self.scdm_backend_detail_label = QLabel()
self.apply_property_button = QPushButton()
self.export_parameters_button = QPushButton()
self.relation_formula_input = QLineEdit()
@@ -160,6 +170,15 @@ class _PropertyTableProbe(QWidget, WindowStateMixin):
self.executed_property_actions.append(("resize_face_height_local", self.face_height_input.text()))
self._after_property_edit_finished(success=True)
def apply_scdm_property_edit(self, spec: dict[str, object] | None = None, target_text: str | None = None) -> None:
self.executed_property_actions.append(
(
"apply_scdm_property_edit",
f"{(spec or {}).get('scdm_capability_key', '')}:{target_text or ''}",
)
)
self._after_property_edit_finished(success=True)
def statusBar(self) -> _StatusBar:
return _StatusBar()
@@ -168,6 +187,26 @@ class _RelationFormulaEventProbe(WindowCoreMixin, _PropertyTableProbe):
pass
class _ScdmAutoPromptProbe(_PropertyTableProbe):
def __init__(self) -> None:
super().__init__()
self.prompt_calls: list[tuple[bool, str, str]] = []
def configure_scdm_backend(self, *, automatic: bool = False, reason: str = "", message: str = "") -> bool:
self.prompt_calls.append((bool(automatic), str(reason), str(message)))
return False
class _PropertyUiRerouteProbe(_PropertyTableProbe):
def __init__(self) -> None:
super().__init__()
self.rerouted_callbacks = 0
def _reroute_to_ui_thread(self, _callback) -> bool:
self.rerouted_callbacks += 1
return True
class _GlobalRelationCompletionModel:
def __init__(self) -> None:
self.faces = [object() for _index in range(100)]
@@ -203,6 +242,12 @@ class _LargeRelationCompletionModel(_GlobalRelationCompletionModel):
raise AssertionError("relation formula completion should not call face_region_logical_id")
class _LargeDisplayModel:
def __init__(self) -> None:
self.faces = [object() for _index in range(1800)]
self.edges = [object() for _index in range(5000)]
class _RelationFormulaRemapModel:
def __init__(self, face_infos: dict[int, dict[str, object]], face_count: int = 120) -> None:
self.faces = [object() for _index in range(face_count)]
@@ -529,6 +574,59 @@ def _assert_property_table_editor(probe: _PropertyTableProbe) -> None:
_assert(preserved_editor.text().strip() == "12", "target value was not preserved after table expand")
def _assert_scdm_command_row_uses_unified_apply() -> None:
probe = _PropertyTableProbe()
probe.scdm_edit_runner_ready = {"feature.fill"}
probe.scdm_feature_cache_state = "ready"
probe.scdm_feature_cache = {
"objects": [
{
"objectId": "hole:0",
"objectType": "hole",
"geometrySignature": {
"faceIds": [0],
"surfaceType": "cylinder",
"radius": 1.0,
"diameter": 2.0,
"center": [0.0, 0.0, 0.0],
},
"capabilities": [
{
"key": "feature.fill",
"displayName": "填孔/删除小特征",
"currentValue": 1,
"valueKind": "command",
"editable": True,
"defaultIntent": "删除并补面",
"backendOperation": "fill_feature",
"postCheck": "target_feature_removed",
}
],
}
]
}
probe._refresh_property_editor()
row = _row_by_label(probe, "填孔/删除小特征")
_assert(probe.property_table.cellWidget(row, PROPERTY_TARGET_COLUMN) is None, "SCDM command row should not have a target editor")
current_item = probe.property_table.item(row, PROPERTY_CURRENT_COLUMN)
target_item = probe.property_table.item(row, PROPERTY_TARGET_COLUMN)
_assert(current_item is not None and current_item.text() == "可执行", f"command current text should be readable: {current_item.text() if current_item else None}")
_assert(target_item is not None and target_item.text() == "执行", f"command target text should be readable: {target_item.text() if target_item else None}")
_assert(not probe.apply_property_button.isEnabled(), "command row should not enable parametric modeling until selected")
probe.property_table.selectRow(row)
QApplication.processEvents()
probe._update_property_apply_state()
pending = probe._pending_property_edit_rows()
_assert(len(pending) == 1 and pending[0][0] == row, f"selected command row should be pending: {pending}")
_assert(probe.apply_property_button.isEnabled(), "selected command row should enable unified parametric modeling button")
probe.apply_current_property_edit()
_assert(
probe.executed_property_actions == [("apply_scdm_property_edit", "feature.fill:执行")],
f"selected command row should execute through SCDM property action: {probe.executed_property_actions}",
)
def _assert_diagnostics_stay_out_of_parameter_table(probe: _PropertyTableProbe) -> None:
long_context = "已按“相邻特征”沿共享边拓扑探测当前特征及 3 个局部关联特征;关联尺寸可在同一参数表中直接修改。"
probe.current_info_values = {
@@ -551,6 +649,13 @@ def _assert_diagnostics_stay_out_of_parameter_table(probe: _PropertyTableProbe)
def _assert_relation_formula_editor() -> None:
probe = _PropertyTableProbe()
probe._refresh_property_editor()
_assert(
not probe.relation_formula_completer_model.stringList(),
"relation formula completions should stay lazy while the formula input is not focused",
)
probe.relation_formula_input.setFocus(Qt.FocusReason.OtherFocusReason)
QApplication.processEvents()
probe._update_relation_formula_completions()
completions = set(probe.relation_formula_completer_model.stringList())
_assert("Face0.面内长度" in completions, f"relation formula completion missing face length: {completions}")
_assert("Face0.面内宽度" in completions, f"relation formula completion missing face width: {completions}")
@@ -1222,6 +1327,31 @@ def _assert_mouse_selection_guards() -> None:
"left-button press/release on different faces should not change selection",
)
mouse_probe = _MouseSelectionProbe()
mouse_probe._large_model_interaction_mode = lambda stats=None: True
mouse_probe.pick_targets = [{"kind": "face", "target_id": 8, "pick_position": (0.0, 0.0, 0.0)}]
mouse_probe._handle_left_button_press(20, 20)
_assert(
len(mouse_probe.pick_targets) == 0,
"large-model left-button press should record the pressed target so background drags cannot select on release",
)
mouse_probe._handle_left_button_release(20, 20)
_assert(
[target.get("target_id") for target in mouse_probe.selected_targets] == [8],
"large-model plain click should select the target recorded on press",
)
mouse_probe = _MouseSelectionProbe()
mouse_probe._large_model_interaction_mode = lambda stats=None: True
mouse_probe.pick_targets = [{"kind": "face", "target_id": 8, "pick_position": (0.0, 0.0, 0.0)}]
mouse_probe._handle_left_button_press(20, 20)
mouse_probe._update_left_button_drag_state(40, 20)
mouse_probe._handle_left_button_release(40, 20)
_assert(
len(mouse_probe.pick_targets) == 0 and not mouse_probe.selected_targets,
"large-model drag/rotation may record the press target but must not select on release",
)
def _assert_quick_blind_depth_spec() -> None:
blind_probe = _PropertyTableProbe()
@@ -1391,6 +1521,362 @@ def _assert_parameter_export_action() -> None:
)
def _assert_scdm_selection_diagnostics() -> None:
probe = _PropertyTableProbe()
probe.scdm_backend_status = {
"path": "D:/softwaresInstallDir/ANSYS Inc/v222/SCDM/SpaceClaim.exe",
"source": "common:D:/softwaresInstallDir/ANSYS Inc",
"version": "v222",
"runScriptOk": True,
"licenseOk": True,
}
probe.scdm_feature_cache_state = "ready"
probe.scdm_edit_runner_ready = {"face.offset"}
probe.scdm_feature_cache = {
"objects": [
{
"objectId": "face:0",
"objectType": "face",
"geometrySignature": {"faceIds": [0], "surfaceType": "plane", "planeOffset": 0.0},
"capabilities": [
{
"key": "face.offset",
"displayName": "偏移",
"currentValue": 0.0,
"valueKind": "number",
"defaultIntent": "推拉平面",
"backendOperation": "pull_face_offset",
"postCheck": "target_face_offset",
}
],
}
]
}
probe._refresh_property_editor()
info = dict(probe.current_info_values)
_assert("SCDM:已配置 v222" in str(info.get("scdm_backend_status")), f"SCDM backend diagnostic missing: {info}")
_assert("识别缓存已就绪" in str(info.get("scdm_runtime_status")), f"SCDM runtime diagnostic missing: {info}")
_assert("SCDM 已识别当前对象" in str(info.get("scdm_selection_status")), f"SCDM selection diagnostic missing: {info}")
_assert("偏移" in str(info.get("scdm_selection_enabled_capabilities")), f"SCDM enabled capability diagnostic missing: {info}")
def _assert_operation_record_backend_sources() -> None:
class _OperationRecordProbe(WindowActionMixin):
pass
stats = SimpleNamespace(solids=1, faces=6, edges=12)
probe = _OperationRecordProbe()
probe.current_info_values = {}
internal_record = probe._make_operation_record(
operation_name="拉伸/切除平面",
target="Face 1",
parameters={"surface": "plane"},
result_message="Planar face push/pull completed: nearest_face=2, actual=10, target=10.",
before_stats=stats,
after_stats=stats,
before_geometry={},
after_geometry={},
target_kind="face",
target_id=1,
target_logical_id=1,
)
_assert("backend: OCCT" in internal_record.detail, f"OCCT backend source missing: {internal_record.detail}")
_assert("execution: Qt background worker" in internal_record.detail, f"OCCT execution source missing: {internal_record.detail}")
_assert(
"recognition: internal StepModel" in internal_record.detail,
f"internal recognition source missing: {internal_record.detail}",
)
asitus_record = probe._make_operation_record(
operation_name="调整孔径",
target="Face 87",
parameters={
"surface": "cylinder",
"asitus_relation_status": "ready",
"analysis_situs_feature_hint_summary": "Analysis Situs hint=hole",
},
result_message="Cylinder resize completed: verified_face=87.",
before_stats=stats,
after_stats=stats,
before_geometry={},
after_geometry={},
target_kind="feature",
target_id=87,
target_logical_id=87,
isolation={"operation": "resize_cylinder"},
)
_assert("backend: OCCT" in asitus_record.detail, f"OCCT backend source missing for Analysis Situs record: {asitus_record.detail}")
_assert(
"execution: isolated OCCT subprocess" in asitus_record.detail,
f"isolated execution source missing: {asitus_record.detail}",
)
_assert(
"recognition: Analysis Situs + internal StepModel" in asitus_record.detail,
f"Analysis Situs recognition source missing: {asitus_record.detail}",
)
_assert(
asitus_record.parameters and asitus_record.parameters.get("recognition_source") == "Analysis Situs + internal StepModel",
f"recognition_source should be stored in record parameters: {asitus_record.parameters}",
)
def _assert_scdm_auto_prompt() -> None:
probe = _ScdmAutoPromptProbe()
probe.maybe_prompt_missing_scdm_backend(reason="probe-failed", message="script failed")
QApplication.processEvents()
_assert(not probe.prompt_calls, f"SCDM prompt should only open for missing backend: {probe.prompt_calls}")
probe.maybe_prompt_missing_scdm_backend(reason="missing-spaceclaim", message="not found")
QApplication.processEvents()
_assert(probe.prompt_calls == [(True, "missing-spaceclaim", "not found")], f"SCDM prompt should open once for missing backend: {probe.prompt_calls}")
probe.maybe_prompt_missing_scdm_backend(reason="missing-spaceclaim", message="still missing")
QApplication.processEvents()
_assert(len(probe.prompt_calls) == 1, f"SCDM prompt should be guarded against repeated popups: {probe.prompt_calls}")
def _assert_property_ui_reroute_guards() -> None:
probe = _PropertyUiRerouteProbe()
probe._refresh_property_editor()
probe._clear_property_editor()
probe._update_current_capability_panel()
probe._sync_scdm_selection_diagnostics()
probe._update_relation_formula_completions()
probe._update_property_apply_state()
_assert(
probe.rerouted_callbacks == 6,
f"property UI entry points should reroute before touching widgets: {probe.rerouted_callbacks}",
)
def _function_text(path: Path, name: str) -> str:
text = path.read_text(encoding="utf-8")
marker = f"def {name}"
start = text.find(marker)
_assert(start >= 0, f"missing function {name} in {path}")
tail = text[start + len(marker) :]
match = re.search(r"\n (?:@Slot\([^\n]*\)\n )?def ", tail)
end = start + len(marker) + match.start() if match else len(text)
return text[start:end]
def _assert_worker_ui_callbacks_guarded() -> None:
targets = {
"step_editor/window_core.py": (
"_finish_scdm_probe_preload",
"_fail_scdm_probe_preload",
"_finish_asitus_hole_recognition",
"_fail_asitus_hole_recognition",
"_finish_initial_load",
"_fail_initial_load",
"_finish_deferred_edge_display",
"_fail_deferred_edge_display",
"_finish_load_refine",
"_fail_load_refine",
),
"step_editor/window_actions.py": (
"_finish_scan_task_result",
"_fail_scan_task_result",
"_finish_edit_action",
"_fail_edit_action",
),
"step_editor/window_state.py": (
"_finish_scdm_edit_action",
"_fail_scdm_edit_action",
"_finish_pending_scdm_edit_reload",
"_fail_pending_scdm_edit_reload",
),
}
for relative_path, names in targets.items():
path = PROJECT_ROOT / relative_path
for name in names:
body = _function_text(path, name)
header = body[:420]
_assert(
"_reroute_to_ui_thread" in header or "_is_ui_thread" in header,
f"{relative_path}:{name} should reroute to the UI thread before touching widgets",
)
def _assert_large_model_preload_stays_lightweight() -> None:
model_body = _function_text(PROJECT_ROOT / "step_editor/model.py", "scdm_local_face_signatures")
_assert("quick_face_info" not in model_body, "SCDM local Face signatures must not run full Face recognition")
_assert("SurfaceProperties" not in model_body, "SCDM local Face signatures should avoid full area/center integration")
quick_cylinder_body = _function_text(PROJECT_ROOT / "step_editor/model.py", "_quick_cylindrical_feature_hint")
internal_graph_body = _function_text(PROJECT_ROOT / "step_editor/model.py", "_can_use_internal_recognition_graph")
_assert(
"len(self.faces) <= 1000" in internal_graph_body,
"large models should not synchronously build the internal full recognition graph",
)
_assert(
"connected_same_domain_face_ids" not in quick_cylinder_body,
"quick cylinder selection must not trigger full same-domain/recognition graph expansion",
)
_assert(
"_connected_cocylindrical_face_ids" in quick_cylinder_body,
"quick cylinder selection should only merge directly connected co-cylindrical fragments",
)
window_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_scdm_local_face_signatures")
_assert("scdm_local_face_signatures" in window_body, "window SCDM preload should use the lightweight model signature API")
_assert("quick_face_info" not in window_body, "window SCDM preload must not call quick_face_info for every Face")
loaded_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_apply_loaded_model_result")
_assert(
"_defer_large_model_recognition_preloads" in loaded_body,
"large model loads should defer full external recognition preloads by default",
)
_assert(
"_start_scdm_probe_preload(force=pending_scdm_reload)" in loaded_body,
"SCDM edit-result reloads should still be able to force cache refresh for validation",
)
preload_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_start_scdm_probe_preload")
_assert(
"local_face_signatures = self._scdm_local_face_signatures()" not in preload_body,
"SCDM preload must not build all local Face signatures on the UI thread",
)
_assert(
"force: bool = False" in preload_body
and "_large_model_interaction_mode()" in preload_body
and "_defer_large_model_recognition_preloads()" in preload_body,
"large-model SCDM preload should be deferred unless a validation path forces it",
)
_assert(
'builder = getattr(model, "scdm_local_face_signatures", None)' in preload_body,
"SCDM preload should build local Face signatures inside the background worker",
)
status_body = _function_text(PROJECT_ROOT / "step_editor/scdm_status.py", "summarize_scdm_runtime")
_assert('state == "deferred"' in status_body, "SCDM status should explain deferred large-model recognition")
def _assert_large_model_selection_stays_lightweight() -> None:
selection_body = _function_text(PROJECT_ROOT / "step_editor/window_state.py", "_feature_info_for_selected_face")
_assert(
"_should_defer_selection_first_level_topology" in selection_body,
"large-model selection should defer full first-level topology expansion",
)
level_body = _function_text(PROJECT_ROOT / "step_editor/window_state.py", "_selection_feature_detection_level")
_assert(
"_large_model_interaction_mode" in level_body and '"current-only"' in level_body,
"large-model feature clicks should force the quick current-feature detection level",
)
defer_body = _function_text(PROJECT_ROOT / "step_editor/window_state.py", "_should_defer_selection_first_level_topology")
_assert(
"return True" in defer_body,
"large-model selection should defer first-level topology even when the combo requests deeper detection",
)
_assert(
"_quick_face_first_level_selection_fields" in selection_body,
"large-model selection should use a quick first-level summary",
)
_assert(
"connected_same_domain_face_ids" not in selection_body
and "_connected_cocylindrical_face_ids" in selection_body,
"large-model feature selection should not trigger full same-domain expansion for cylinders",
)
quick_body = _function_text(PROJECT_ROOT / "step_editor/window_state.py", "_quick_face_first_level_selection_fields")
_assert(
"face_first_level_topology" not in quick_body and "face_first_level_facts" not in quick_body,
"quick large-model selection summary must not run full topology/fact graph builders",
)
loaded_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_apply_loaded_model_result")
_assert("large_interaction_model" in loaded_body, "large models should be detected after load")
_assert(
"hide_edges_during_camera_interaction" in loaded_body,
"large models should hide edge overlay during camera interaction",
)
_assert(
"large_model_edge_overlay_skipped = True" in loaded_body,
"large models should skip deferred full-edge overlay during default viewing",
)
_assert(
"large_model_hover_disabled = large_interaction_model" in loaded_body,
"large models should disable hover picking/highlighting to avoid pointer stalls",
)
rebuild_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_rebuild_scene")
_assert(
"_empty_edge_polydata()" in rebuild_body and "large_model_edge_overlay_skipped" in rebuild_body,
"large-model scene rebuilds should keep edge overlay lazy by default",
)
mode_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_on_mode_changed")
_assert(
"_rebuild_deferred_edge_display" in mode_body and '"Edge"' in mode_body,
"Edge selection mode should restore deferred edge display on demand",
)
hover_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_queue_hover_position")
_assert(
"large_model_hover_disabled" in hover_body,
"large-model hover picking should be suppressed before the VTK picker runs",
)
action_body = _function_text(PROJECT_ROOT / "step_editor/window_actions.py", "_isolation_for_plan")
_assert(
"_prefer_isolated_process_for_large_interactive_edit" in action_body
and "large-model-smooth-ui-isolated-occ-edit" in action_body,
"large complex push/pull rebuilds should use an independent background process for smoother interaction",
)
job_body = _function_text(PROJECT_ROOT / "step_editor/window_actions.py", "_make_edit_job")
_assert(
"skip_before_quality_check" in job_body,
"large-model edit jobs should be able to skip the pre-edit full B-Rep check while preserving post-edit validation",
)
finish_body = _function_text(PROJECT_ROOT / "step_editor/window_actions.py", "_finish_edit_action")
_assert(
"large_model_edge_overlay_skipped" in finish_body
and 'not bool(getattr(self, "large_model_edge_overlay_skipped", False))' in finish_body,
"large-model edit finish should not automatically rebuild full edge overlay",
)
def _assert_large_planar_offset_prefers_local_backend() -> None:
probe = _PropertyTableProbe()
probe.model = _LargeDisplayModel()
probe.selected_kind = "feature"
probe.selected_face_id = 0
probe.scdm_feature_cache_state = "ready"
probe.scdm_feature_cache = {
"objects": [
{
"objectId": "face:0",
"objectType": "face",
"geometrySignature": {"objectType": "face", "faceIds": [0], "surfaceType": "plane"},
"capabilities": [
{
"key": "face.offset",
"displayName": "偏移",
"currentValue": 57.5,
"editable": True,
"defaultIntent": "推拉平面",
"backendOperation": "pull_face_offset",
"postCheck": "target_face_offset",
}
],
}
]
}
action_info = probe._selected_action_info()
action_info.update({"inner_boundary_wires": 5, "boundary_edges": 61})
specs = probe._property_editor_specs(action_info, action_info)
_assert(specs, "large planar Face should still expose local editable specs")
_assert(
all(str(spec.get("action") or "") != "apply_scdm_property_edit" for spec in specs),
f"large multi-boundary planar offset should not route to SCDM: {specs}",
)
offset = next((spec for spec in specs if str(spec.get("key") or "") == "face_target_normal_position"), None)
_assert(isinstance(offset, dict), f"local Face offset spec should be present: {specs}")
_assert(
str(offset.get("action") or "") in {"push_pull_face", "push_pull_face_keep_relations"},
f"local Face offset should use optimized OCCT path: {offset}",
)
_assert("SCDM" in str(probe.scdm_selection_status_message), "backend preference should explain that SCDM was bypassed")
def _assert_background_load_uses_worker() -> None:
body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_load_step_background_or_sync")
_assert("LoadWorker(action)" in body, "background STEP load should run through LoadWorker")
_assert("worker.moveToThread(thread)" in body, "background STEP load worker should move to QThread")
_assert("_finish_initial_load" in body and "_fail_initial_load" in body, "background STEP load should finish through queued callbacks")
_assert(
"_run_deferred_initial_load(path)" not in body,
"background STEP load must not fall back to main-thread deferred loading",
)
def main() -> int:
app = QApplication.instance() or QApplication([])
top_level_label_probe = _TopLevelPropertyLabelProbe()
@@ -1406,13 +1892,55 @@ def main() -> int:
f"property labels were shown as transient top-level windows: {top_level_label_probe.shown_labels}",
)
_assert_property_table_editor(probe)
_assert("当前支持" in probe.current_capability_headline.text(), "software progress panel did not show supported areas")
_assert("优先:" not in probe.current_capability_headline.text(), "software progress panel should not show priority copy")
_assert(probe.current_capability_button.text() == "软件进度", "software progress should be a compact button")
_assert("当前支持" in probe.current_capability_button.toolTip(), "software progress button tooltip did not show supported areas")
_assert("优先:" not in probe.current_capability_button.toolTip(), "software progress button tooltip should not show priority copy")
_assert(
"矩形槽口袋" in probe.current_capability_headline.toolTip()
and "多台阶矩形凸台顶层" in probe.current_capability_headline.toolTip(),
"software progress tooltip should name newly supported prismatic feature edits",
"能改:" in probe.current_capability_button.toolTip()
and "能识别:" in probe.current_capability_button.toolTip()
and "暂不能:" in probe.current_capability_button.toolTip(),
f"software progress tooltip should be customer-facing capability copy: {probe.current_capability_button.toolTip()}",
)
_assert("当前 cache" not in probe.current_capability_button.toolTip(), "software progress tooltip should hide cache internals")
_assert("SCDM 能力:" not in probe.current_capability_button.toolTip(), "software progress tooltip should hide SCDM internals")
_assert(not hasattr(probe, "configure_scdm_button"), "software progress should not expose a persistent SCDM configure button")
progress_detail = probe._software_progress_detail_text()
_assert("# 软件进度" in progress_detail, "software progress dialog text should be Markdown-like")
_assert("## 已能修改" in progress_detail, "software progress dialog text should list editable capabilities first")
_assert("## 已能识别" in progress_detail, "software progress dialog text should list recognized capabilities")
_assert("## 暂不能修改" in progress_detail, "software progress dialog text should list unsupported edits")
_assert("## 暂不能稳定识别" in progress_detail, "software progress dialog text should list unsupported recognition")
_assert("孔组" in progress_detail and "一级关系" in progress_detail, "software progress dialog text should include recognition scope")
_assert("B-Rep 校验" in progress_detail and "原 CAD 历史树" in progress_detail, "software progress dialog text should explain limits")
_assert("SCDM-first 路线状态" not in progress_detail, "software progress dialog text should hide roadmap internals")
_assert("SCDM 能力报告" not in progress_detail, "software progress dialog text should hide dynamic SCDM internals")
probe.scdm_backend_status = {
"path": "D:/softwaresInstallDir/ANSYS Inc/v222/SCDM/SpaceClaim.exe",
"source": "common:D:/softwaresInstallDir/ANSYS Inc",
"version": "v222",
"runScriptOk": True,
"licenseOk": True,
}
probe.scdm_feature_cache_state = "ready"
probe.scdm_feature_cache = {
"objects": [
{"objectId": "face:1", "capabilities": [{"key": "face.offset"}]},
{"objectId": "hole:1", "capabilities": [{"key": "hole.diameter"}, {"key": "hole.position"}]},
],
"diagnostics": {
"geometry_candidate_hints": [
{"capabilityKey": "boss.height", "displayName": "凸台高度", "evidenceCount": 2, "confidence": "low"}
]
},
}
probe._update_current_capability_panel()
configured_detail = probe._software_progress_detail_text()
_assert("已配置 v222" not in configured_detail, f"SCDM progress detail should hide backend version: {configured_detail}")
_assert("当前 cache 可执行" not in configured_detail, f"SCDM progress detail should hide cache count: {configured_detail}")
_assert("2 个对象" not in probe.current_capability_button.toolTip(), f"SCDM cache status should stay out of tooltip: {probe.current_capability_button.toolTip()}")
_assert("SCDM 能力:" not in probe.current_capability_button.toolTip(), f"SCDM progress tooltip should hide capability counters: {probe.current_capability_button.toolTip()}")
_assert("几何证据" not in probe.current_capability_button.toolTip(), f"SCDM progress tooltip should hide geometry hint counts: {probe.current_capability_button.toolTip()}")
_assert("凸台高度:几何证据待分类" not in configured_detail, f"SCDM detail should hide geometry-only hints: {configured_detail}")
_assert_diagnostics_stay_out_of_parameter_table(probe)
_assert_relation_formula_editor()
@@ -1423,9 +1951,19 @@ def main() -> int:
_assert_relation_formula_input_recovers_after_loading()
_assert_relation_formula_ids_follow_model_remap()
_assert_mouse_selection_guards()
_assert_scdm_selection_diagnostics()
_assert_operation_record_backend_sources()
_assert_scdm_auto_prompt()
_assert_property_ui_reroute_guards()
_assert_worker_ui_callbacks_guarded()
_assert_large_model_preload_stays_lightweight()
_assert_large_model_selection_stays_lightweight()
_assert_large_planar_offset_prefers_local_backend()
_assert_background_load_uses_worker()
_assert_quick_blind_depth_spec()
_assert_user_facing_failure_messages()
_assert_parameter_export_action()
_assert_scdm_command_row_uses_unified_apply()
print("property table editor UI ok")
if QApplication.instance() is app:
+157
View File
@@ -0,0 +1,157 @@
from __future__ import annotations
import ast
import os
import re
import subprocess
import sys
import tempfile
from contextlib import contextmanager
from pathlib import Path
from typing import Iterator
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.scdm_backend import ( # noqa: E402
SCDM_DISABLE_ENV,
SCDM_PATH_ENV_VARS,
ScdmBackendInfo,
default_scdm_cache_path,
discover_scdm_backend_candidates,
load_scdm_backend_cache,
resolve_scdm_backend,
save_scdm_backend_cache,
scdm_run_script_command,
verify_scdm_backend,
)
def _assert(condition: bool, message: str) -> None:
if not condition:
raise AssertionError(message)
@contextmanager
def _patched_env(values: dict[str, str | None]) -> Iterator[None]:
original = {key: os.environ.get(key) for key in values}
try:
for key, value in values.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
yield
finally:
for key, value in original.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
def _fake_spaceclaim(path: Path) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("fake", encoding="utf-8")
return path
def _fake_runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
script_args = [item for item in command if item.startswith("/RunScript=")]
_assert(script_args, f"missing /RunScript argument: {command}")
script_path = Path(script_args[0].split("=", 1)[1])
script = script_path.read_text(encoding="utf-8")
match = re.search(r"report_path\s*=\s*(.+)", script)
_assert(match is not None, f"smoke script should define report_path: {script}")
report_path = Path(ast.literal_eval(match.group(1).strip()))
report_path.write_text('{"ok": true, "version": "fake-2022R2", "message": "fake smoke ok"}', encoding="utf-8")
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
def main() -> int:
with tempfile.TemporaryDirectory(prefix="step_editor_scdm_backend_") as temp:
root = Path(temp)
fake_exe = _fake_spaceclaim(root / "ANSYS Inc" / "v222" / "SCDM" / "SpaceClaim.exe")
backend = ScdmBackendInfo(
path=fake_exe,
source="test",
version="v222",
verified_at="2026-08-18T00:00:00Z",
run_script_ok=True,
license_ok=True,
message="cached",
)
cache_path = save_scdm_backend_cache(backend, project_root_override=root)
_assert(cache_path == default_scdm_cache_path(root), f"unexpected cache path: {cache_path}")
loaded = load_scdm_backend_cache(project_root_override=root)
_assert(loaded is not None, "cache should load")
_assert(loaded.path == fake_exe.resolve(strict=False), f"cache should preserve path: {loaded}")
_assert(loaded.run_script_ok is True and loaded.license_ok is True, f"cache should preserve verification: {loaded}")
with tempfile.TemporaryDirectory(prefix="step_editor_scdm_backend_env_") as temp:
root = Path(temp)
fake_exe = _fake_spaceclaim(root / "SpaceClaim.exe")
env_clear = {name: None for name in SCDM_PATH_ENV_VARS}
env_clear[SCDM_DISABLE_ENV] = None
env_clear["STEP_EDITOR_SCDM_EXE"] = str(fake_exe)
with _patched_env(env_clear):
candidates = discover_scdm_backend_candidates(
include_registry=False,
include_common=False,
include_path=False,
)
_assert(len(candidates) == 1, f"env discovery should find exactly one candidate: {candidates}")
_assert(candidates[0].source == "env:STEP_EDITOR_SCDM_EXE", f"bad source: {candidates[0]}")
resolved = resolve_scdm_backend(
project_root_override=root,
validate=False,
include_registry=False,
include_common=False,
include_path=False,
)
_assert(resolved.get("ok") is True, f"env backend should resolve: {resolved}")
_assert(Path(str(resolved.get("path"))) == fake_exe.resolve(strict=False), f"bad resolved path: {resolved}")
_assert(load_scdm_backend_cache(project_root_override=root) is not None, "resolve should write cache")
with tempfile.TemporaryDirectory(prefix="step_editor_scdm_backend_common_") as temp:
root = Path(temp)
common_root = root / "Program Files" / "ANSYS Inc"
fake_exe = _fake_spaceclaim(common_root / "v231" / "SCDM" / "SpaceClaim.exe")
candidates = discover_scdm_backend_candidates(
include_env=False,
include_registry=False,
include_common=True,
include_path=False,
common_roots=(common_root,),
)
_assert(candidates and candidates[0].path == fake_exe.resolve(strict=False), f"common discovery failed: {candidates}")
_assert(candidates[0].version == "v231", f"version should be parsed from ANSYS folder: {candidates[0]}")
with tempfile.TemporaryDirectory(prefix="step_editor_scdm_backend_smoke_") as temp:
root = Path(temp)
fake_exe = _fake_spaceclaim(root / "SpaceClaim.exe")
command = scdm_run_script_command(fake_exe, root / "smoke.py")
_assert(command[0].endswith("SpaceClaim.exe"), f"bad command executable: {command}")
_assert(any(item.startswith("/RunScript=") for item in command), f"bad command script arg: {command}")
smoke = verify_scdm_backend(fake_exe, work_dir=root, runner=_fake_runner)
_assert(smoke.get("ok") is True, f"fake smoke should pass: {smoke}")
_assert(smoke.get("runScriptOk") is True and smoke.get("licenseOk") is True, f"bad smoke flags: {smoke}")
with tempfile.TemporaryDirectory(prefix="step_editor_scdm_backend_disabled_") as temp:
root = Path(temp)
with _patched_env({SCDM_DISABLE_ENV: "1"}):
resolved = resolve_scdm_backend(project_root_override=root, validate=False)
_assert(resolved.get("ok") is False and resolved.get("reason") == "disabled", f"disable env failed: {resolved}")
missing = verify_scdm_backend(Path("Z:/not-installed/SpaceClaim.exe"))
_assert(missing.get("ok") is False and missing.get("reason") == "missing-exe", f"missing path should be clean: {missing}")
print("scdm backend discovery ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+351
View File
@@ -0,0 +1,351 @@
from __future__ import annotations
import ast
import re
import subprocess
import sys
import tempfile
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.scdm_backend import ScdmBackendInfo # noqa: E402
from step_editor.scdm_edit_runner import generate_scdm_edit_script, prepare_scdm_edit_job, run_scdm_edit_job # noqa: E402
from step_editor.scdm_schema import read_json, write_json # noqa: E402
def _assert(condition: bool, message: str) -> None:
if not condition:
raise AssertionError(message)
def _fake_spaceclaim(path: Path) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("fake", encoding="utf-8")
return path
def _script_path_from_command(command: list[str] | tuple[str, ...]) -> Path:
for item in command:
if item.startswith("/RunScript="):
return Path(item.split("=", 1)[1])
raise AssertionError(f"missing /RunScript argument: {command}")
def _job_path_from_script(script_path: Path) -> Path:
text = script_path.read_text(encoding="utf-8")
match = re.search(r"^JOB_PATH = (.+)$", text, flags=re.MULTILINE)
_assert(match is not None, f"generated script should embed JOB_PATH: {script_path}")
return Path(ast.literal_eval(match.group(1)))
def _successful_runner(command, **_kwargs): # type: ignore[no-untyped-def]
script_path = _script_path_from_command(command)
job_path = _job_path_from_script(script_path)
job = read_json(job_path)
outputs = job.get("outputs")
_assert(isinstance(outputs, dict), f"job outputs should be an object: {job}")
output_step = Path(str(outputs["outputStep"]))
output_step.write_text("ISO-10303-21;\n/* fake SCDM output */\nEND-ISO-10303-21;\n", encoding="utf-8")
write_json(
outputs["result"],
{
"ok": True,
"reason": "ok",
"message": "fake edit finished",
"outputStep": str(output_step),
"backendOperation": job.get("target", {}).get("backendOperation"),
},
)
return subprocess.CompletedProcess(command, 0, stdout="ok", stderr="")
def _failure_runner(command, **_kwargs): # type: ignore[no-untyped-def]
script_path = _script_path_from_command(command)
job_path = _job_path_from_script(script_path)
job = read_json(job_path)
outputs = job.get("outputs")
_assert(isinstance(outputs, dict), f"job outputs should be an object: {job}")
write_json(
outputs["error"],
{
"ok": False,
"reason": "fake-failed",
"message": "fake SCDM command failed",
"backendOperation": job.get("target", {}).get("backendOperation"),
},
)
return subprocess.CompletedProcess(command, 7, stdout="", stderr="fake failure")
def _missing_output_runner(command, **_kwargs): # type: ignore[no-untyped-def]
script_path = _script_path_from_command(command)
job_path = _job_path_from_script(script_path)
job = read_json(job_path)
outputs = job.get("outputs")
_assert(isinstance(outputs, dict), f"job outputs should be an object: {job}")
write_json(
outputs["result"],
{
"ok": True,
"reason": "ok",
"message": "fake success without STEP",
"outputStep": str(outputs["outputStep"]),
},
)
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
def _empty_output_runner(command, **_kwargs): # type: ignore[no-untyped-def]
script_path = _script_path_from_command(command)
job_path = _job_path_from_script(script_path)
job = read_json(job_path)
outputs = job.get("outputs")
_assert(isinstance(outputs, dict), f"job outputs should be an object: {job}")
Path(str(outputs["outputStep"])).write_text("", encoding="utf-8")
write_json(
outputs["result"],
{
"ok": True,
"reason": "ok",
"message": "fake success with empty STEP",
"outputStep": str(outputs["outputStep"]),
},
)
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
def main() -> int:
with tempfile.TemporaryDirectory(prefix="step_editor_scdm_edit_") as temp:
root = Path(temp)
step_path = root / "sample.step"
step_path.write_text("ISO-10303-21;\nEND-ISO-10303-21;\n", encoding="utf-8")
backend = ScdmBackendInfo(path=_fake_spaceclaim(root / "SpaceClaim.exe"), source="test", version="v222")
signature = {
"objectType": "hole",
"faceIds": [85, 94],
"bodyIndex": 0,
"faceOrdinal": 12,
"faceOrdinals": [12, 19],
"scdmFaceLocators": [
{"bodyIndex": 0, "faceOrdinal": 12, "globalFaceOrdinal": 85},
{"bodyIndex": 0, "faceOrdinal": 19, "globalFaceOrdinal": 96},
],
"center": [0.5, 1.0, 9.5],
"axis": [0.0, 0.0, 1.0],
}
prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "prepared",
backend=backend,
capability_key="hole.diameter",
target_value="0.75",
object_id="hole:85-94",
object_signature=signature,
timeout_seconds=45.0,
)
_assert(prepared.get("ok") is True, f"edit job should be prepared: {prepared}")
job_path = Path(str(prepared["job_path"]))
script_path = Path(str(prepared["script_path"]))
_assert(job_path.is_file(), "scdm_edit_job.json should be written")
_assert(script_path.is_file(), "scdm_edit.py should be written")
job = read_json(job_path)
_assert(job.get("adapter") == "spaceclaim-v1", f"bad adapter: {job}")
_assert(job.get("schemaVersion") == 1, f"bad schema version: {job}")
_assert(job.get("model", {}).get("sourceStep") == str(step_path.resolve(strict=False)), f"bad source path: {job}")
_assert(job.get("model", {}).get("rollbackStep") == str(step_path.resolve(strict=False)), f"bad rollback path: {job}")
_assert(job.get("target", {}).get("capabilityKey") == "hole.diameter", f"bad capability: {job}")
_assert(job.get("target", {}).get("backendOperation") == "change_hole_diameter", f"bad operation: {job}")
_assert(job.get("target", {}).get("value") == 0.75, f"target should be numeric: {job}")
_assert(job.get("object", {}).get("geometrySignature", {}).get("faceIds") == [85, 94], f"bad signature: {job}")
_assert(job.get("execution", {}).get("isolatedProcess") is True, f"job should record isolated execution: {job}")
script = script_path.read_text(encoding="utf-8")
for token in (
"change_hole_diameter",
"move_hole_axis",
"move_slot",
"move_boss",
"pull_face_offset",
"fill_feature",
"StandardHoles.ModifyHoleRadius",
"OffsetFaces.Execute",
"Move.Translate",
"MoveOptions",
"OffsetFaceOptions",
"FillOptions",
"FillMode",
"Delete.Execute",
"DocumentSave",
"scdmFaceLocators",
"result.json",
"error.json",
):
_assert(token in script, f"generated edit script missing {token}")
_assert("Pull.Execute" not in script, "generated edit script should use documented OffsetFaces instead of Pull.Execute")
generated = generate_scdm_edit_script(job_path)
_assert("JOB_PATH =" in generated and job_path.name in generated, "generated edit script should embed the job path")
slot_signature = {
"objectType": "slot",
"faceIds": [30, 31, 32],
"bodyIndex": 0,
"faceOrdinal": 30,
"faceOrdinals": [30, 31, 32],
"globalFaceOrdinal": 40,
"globalFaceOrdinals": [40, 41, 42],
"center": [1.0, 2.0, 3.0],
"axis": [1.0, 0.0, 0.0],
}
slot_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "slot-position-prepared",
backend=backend,
capability_key="slot.position",
target_value=[1.0, 2.0, 5.0],
object_id="slot:30-31-32",
object_signature=slot_signature,
)
_assert(slot_prepared.get("ok") is True, f"slot.position should be productized and prepare an edit job: {slot_prepared}")
slot_job = read_json(slot_prepared["job_path"])
_assert(slot_job.get("target", {}).get("backendOperation") == "move_slot", f"slot.position should route to move_slot: {slot_job}")
_assert(slot_job.get("target", {}).get("value") == [1.0, 2.0, 5.0], f"slot.position target should be vector3: {slot_job}")
_assert(slot_job.get("object", {}).get("geometrySignature", {}).get("faceIds") == [30, 31, 32], f"slot faces should be preserved: {slot_job}")
boss_signature = {
"objectType": "cylindrical_boss",
"faceIds": [50, 51, 52],
"bodyIndex": 0,
"faceOrdinal": 50,
"faceOrdinals": [50, 51, 52],
"globalFaceOrdinal": 70,
"globalFaceOrdinals": [70, 71, 72],
"center": [0.0, 0.0, 2.0],
"axis": [0.0, 0.0, 1.0],
}
boss_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "boss-position-prepared",
backend=backend,
capability_key="boss.position",
target_value=[2.0, 0.0, 2.0],
object_id="boss:50-51-52",
object_signature=boss_signature,
)
_assert(boss_prepared.get("ok") is True, f"boss.position should be productized and prepare an edit job: {boss_prepared}")
boss_job = read_json(boss_prepared["job_path"])
_assert(boss_job.get("target", {}).get("backendOperation") == "move_boss", f"boss.position should route to move_boss: {boss_job}")
_assert(boss_job.get("target", {}).get("value") == [2.0, 0.0, 2.0], f"boss.position target should be vector3: {boss_job}")
_assert(boss_job.get("object", {}).get("geometrySignature", {}).get("faceIds") == [50, 51, 52], f"boss faces should be preserved: {boss_job}")
planned = prepare_scdm_edit_job(
step_path,
output_dir=root / "unsupported",
backend=backend,
capability_key="slot.width",
target_value="1",
object_signature=signature,
)
_assert(
planned.get("ok") is False and planned.get("reason") == "capability-not-productized",
f"planned capability should fail early with a roadmap reason: {planned}",
)
unsupported = prepare_scdm_edit_job(
step_path,
output_dir=root / "unsupported-unknown",
backend=backend,
capability_key="not.real",
target_value="1",
object_signature=signature,
)
_assert(unsupported.get("ok") is False and unsupported.get("reason") == "unsupported-capability", f"unknown capability should fail early: {unsupported}")
success = run_scdm_edit_job(
step_path,
output_dir=root / "success",
backend=backend,
capability_key="hole.diameter",
target_value=0.9,
object_id="hole:85-94",
object_signature=signature,
runner=_successful_runner,
)
_assert(success.get("ok") is True, f"fake edit should succeed: {success}")
_assert(Path(str(success["output_step"])).is_file(), f"output STEP should exist: {success}")
success_backend = success.get("backend")
_assert(isinstance(success_backend, dict), f"successful edit should carry backend status: {success}")
_assert(success_backend.get("runScriptOk") is True, f"successful edit should mark /RunScript usable: {success_backend}")
_assert(success_backend.get("licenseOk") is True, f"successful edit should mark license usable: {success_backend}")
failed = run_scdm_edit_job(
step_path,
output_dir=root / "failed",
backend=backend,
capability_key="hole.position",
target_value=[0.5, 1.0, 6.0],
object_id="hole:85-94",
object_signature=signature,
runner=_failure_runner,
)
_assert(failed.get("ok") is False and failed.get("reason") == "fake-failed", f"error.json should drive failure reason: {failed}")
slot_success = run_scdm_edit_job(
step_path,
output_dir=root / "slot-success",
backend=backend,
capability_key="slot.position",
target_value=[1.0, 2.0, 6.0],
object_id="slot:30-31-32",
object_signature=slot_signature,
runner=_successful_runner,
)
_assert(slot_success.get("ok") is True, f"fake slot.position edit should succeed: {slot_success}")
_assert(slot_success.get("backend_operation") == "move_slot", f"slot.position should report move_slot: {slot_success}")
boss_success = run_scdm_edit_job(
step_path,
output_dir=root / "boss-success",
backend=backend,
capability_key="boss.position",
target_value=[3.0, 0.0, 2.0],
object_id="boss:50-51-52",
object_signature=boss_signature,
runner=_successful_runner,
)
_assert(boss_success.get("ok") is True, f"fake boss.position edit should succeed: {boss_success}")
_assert(boss_success.get("backend_operation") == "move_boss", f"boss.position should report move_boss: {boss_success}")
missing_output = run_scdm_edit_job(
step_path,
output_dir=root / "missing-output",
backend=backend,
capability_key="face.offset",
target_value=5,
object_id="face:9",
object_signature={"objectType": "face", "faceIds": [9], "bodyIndex": 0, "faceOrdinal": 3},
runner=_missing_output_runner,
)
_assert(missing_output.get("ok") is False and missing_output.get("reason") == "missing-output-step", f"missing output STEP should be rejected: {missing_output}")
empty_output = run_scdm_edit_job(
step_path,
output_dir=root / "empty-output",
backend=backend,
capability_key="face.offset",
target_value=5,
object_id="face:9",
object_signature={"objectType": "face", "faceIds": [9], "bodyIndex": 0, "faceOrdinal": 3},
runner=_empty_output_runner,
)
_assert(empty_output.get("ok") is False and empty_output.get("reason") == "empty-output-step", f"empty output STEP should be rejected: {empty_output}")
print("scdm edit runner ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+550
View File
@@ -0,0 +1,550 @@
from __future__ import annotations
import ast
import re
import subprocess
import sys
import tempfile
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.scdm_backend import ScdmBackendInfo, load_scdm_backend_cache # noqa: E402
from step_editor.scdm_feature_mapper import attach_local_face_ids_to_scdm_cache, geometry_signature, map_scdm_raw_features, map_scdm_raw_features_file # noqa: E402
from step_editor.scdm_probe import generate_scdm_probe_script, prepare_scdm_probe_job, run_scdm_probe # noqa: E402
from step_editor.scdm_property_specs import property_specs_from_scdm_cache # noqa: E402
from step_editor.scdm_schema import read_json, write_json # noqa: E402
def _assert(condition: bool, message: str) -> None:
if not condition:
raise AssertionError(message)
def _capability_keys(cache: dict[str, object], object_id_part: str) -> set[str]:
objects = cache.get("objects")
_assert(isinstance(objects, list), f"cache objects should be a list: {cache}")
for item in objects:
if not isinstance(item, dict):
continue
if object_id_part not in str(item.get("objectId") or ""):
continue
capabilities = item.get("capabilities")
_assert(isinstance(capabilities, list), f"capabilities should be a list: {item}")
return {str(capability.get("key")) for capability in capabilities if isinstance(capability, dict)}
return set()
def _script_path_from_command(command: list[str] | tuple[str, ...]) -> Path:
for item in command:
if item.startswith("/RunScript="):
return Path(item.split("=", 1)[1])
raise AssertionError(f"missing /RunScript argument: {command}")
def _job_path_from_script(script_path: Path) -> Path:
text = script_path.read_text(encoding="utf-8")
match = re.search(r"^JOB_PATH = (.+)$", text, flags=re.MULTILINE)
_assert(match is not None, f"generated probe script should embed JOB_PATH: {script_path}")
return Path(ast.literal_eval(match.group(1)))
def _successful_probe_runner(command, **_kwargs): # type: ignore[no-untyped-def]
script_path = _script_path_from_command(command)
job_path = _job_path_from_script(script_path)
job = read_json(job_path)
outputs = job.get("outputs")
_assert(isinstance(outputs, dict), f"job outputs should be an object: {job}")
write_json(
outputs["rawFeatures"],
{
"schemaVersion": 1,
"backend": job.get("backend", {}),
"model": job.get("model", {}),
"diagnostics": {"availableCommands": [{"name": "OffsetFaces", "available": True}]},
"objects": [],
},
)
return subprocess.CompletedProcess(command, 0, stdout="ok", stderr="")
def main() -> int:
with tempfile.TemporaryDirectory(prefix="step_editor_scdm_probe_") as temp:
root = Path(temp)
step_path = root / "sample.step"
step_path.write_text("ISO-10303-21;\nEND-ISO-10303-21;\n", encoding="utf-8")
backend = ScdmBackendInfo(path=root / "SpaceClaim.exe", source="test", version="v222")
backend.path.write_text("fake", encoding="utf-8")
prepared = prepare_scdm_probe_job(step_path, output_dir=root / "probe", project_root=root, backend=backend)
_assert(prepared.get("ok") is True, f"probe job should be prepared: {prepared}")
job_path = Path(str(prepared["job_path"]))
script_path = Path(str(prepared["script_path"]))
raw_path = Path(str(prepared["raw_features_path"]))
_assert(job_path.is_file(), "scdm_probe_job.json should be written")
_assert(script_path.is_file(), "scdm_probe.py should be written")
job = read_json(job_path)
_assert(job.get("adapter") == "spaceclaim-v1", f"bad adapter: {job}")
_assert(job.get("outputs", {}).get("rawFeatures") == str(raw_path), f"bad raw output path: {job}")
script = script_path.read_text(encoding="utf-8")
_assert("GetRootPart" in script, "probe script should inspect the active root part")
_assert("GetHoleFaces" in script, "probe script should ask SCDM for standard hole faces")
_assert("StandardHoles" in script and "FindStandardHoleOptions" in script and "getattr(standard_holes, 'Find'" in script, "probe script should try SCDM StandardHoles.Find before geometric fallback")
_assert("availableCommands" in script and "ConstantRound" in script and "Chamfer" in script, "probe script should report SCDM command availability")
_assert("RoundInfo" in script and "_round_info_from_face" in script and "change_round_radius" in script, "probe script should collect SCDM round diagnostics")
_assert("backendCommandCandidates" in script, "probe script should write raw command candidates")
for token in ("_geometry_from_edge", "Length", "StartPoint", "EndPoint", "adjacentFaceOrdinals"):
_assert(token in script, f"probe script should enrich Edge raw geometry with {token}")
for token in ("_record_face_adjacency", "_face_adjacency_rows", "edgeGeometrySummary", "_final_edge_geometry_summary", "_feature_inventory"):
_assert(token in script, f"probe script should summarize SCDM topology evidence with {token}")
generated = generate_scdm_probe_script(job_path)
_assert("JOB_PATH =" in generated and job_path.name in generated, "generated probe script should embed the job path")
probe_result = run_scdm_probe(step_path, backend=backend, output_dir=root / "run-probe", project_root=root, runner=_successful_probe_runner)
_assert(probe_result.get("ok") is True, f"fake run_scdm_probe should pass: {probe_result}")
probe_backend = probe_result.get("backend")
_assert(isinstance(probe_backend, dict), f"probe result should carry backend status: {probe_result}")
_assert(probe_backend.get("runScriptOk") is True, f"successful probe should mark /RunScript usable: {probe_backend}")
_assert(probe_backend.get("licenseOk") is True, f"successful probe should mark license usable: {probe_backend}")
cached_backend = load_scdm_backend_cache(project_root_override=root)
_assert(cached_backend is not None and cached_backend.run_script_ok is True, f"successful probe should update backend cache: {cached_backend}")
raw = {
"schemaVersion": 1,
"backend": {"name": "SCDM", "version": "v222"},
"model": {"path": str(step_path), "fingerprint": "abc123", "unit": "mm"},
"diagnostics": {
"availableCommands": [
{"name": "StandardHoles", "available": True},
{"name": "OffsetFaces", "available": True},
{"name": "Move", "available": True},
{"name": "Fill", "available": True},
{"name": "Delete", "available": True},
{"name": "ConstantRound", "available": True},
{"name": "SomeFutureCommand", "available": False},
],
"faceAdjacency": [
{
"bodyIndex": 0,
"faceOrdinals": [1, 2],
"edgeCount": 1,
"edgeKinds": {"circular": 1},
"edges": [{"edgeOrdinal": 7, "globalEdgeOrdinal": 9, "kind": "circular", "radius": 0.25}],
}
],
"edgeGeometrySummary": {
"totalEdgeCount": 9,
"edgeKindCounts": {"linear": 5, "circular": 4},
"circularEdgeCount": 4,
"circularRadiusBuckets": [{"radius": "0.25", "count": 4}],
"minEdgeLength": 0.5,
"maxEdgeLength": 8.0,
},
"featureInventory": {
"objectTypeCounts": {"face": 2, "hole": 1, "edge": 1, "slot": 1, "round": 1},
"surfaceTypeCounts": {"plane": 1, "cylinder": 3},
"curveTypeCounts": {"Line": 5, "Circle": 4},
"operationCounts": {
"pull_face_offset": 1,
"change_hole_diameter": 1,
"change_slot_width": 1,
"move_slot": 1,
"change_boss_height": 1,
"move_boss": 1,
},
},
},
"summary": {"bodyCount": 1, "objectCount": 10, "faceCount": 6, "edgeCount": 1, "holeFaceCount": 0},
"objects": [
{
"backendId": "hole:1",
"objectType": "hole",
"geometry": {
"diameter": 0.5,
"center": [0.5, 1.0, 9.5],
"axis": [0.0, 0.0, 1.0],
},
"topologyHint": {"faceIds": [85, 94], "bodyIndex": 0, "faceOrdinal": 12},
"backendCommandCandidates": [
{"operation": "change_hole_diameter", "enabled": True, "parameterFields": {"diameter": 0.5}},
{"operation": "move_hole_axis", "enabled": True, "parameterFields": {"center": [0.5, 1.0, 9.5]}},
],
"rawLimitations": [],
},
{
"backendId": "hole:pattern-a",
"objectType": "hole",
"geometry": {
"diameter": 0.5,
"center": [0.0, 0.0, 0.0],
"axis": [0.0, 0.0, 1.0],
},
"topologyHint": {"faceIds": [200], "bodyIndex": 2, "faceOrdinal": 1},
"backendCommandCandidates": [
{"operation": "change_hole_diameter", "enabled": True, "parameterFields": {"diameter": 0.5}},
{"operation": "move_hole_axis", "enabled": True, "parameterFields": {"center": [0.0, 0.0, 0.0]}},
],
"rawLimitations": [],
},
{
"backendId": "hole:pattern-b",
"objectType": "hole",
"geometry": {
"diameter": 0.5,
"center": [5.0, 0.0, 0.0],
"axis": [0.0, 0.0, 1.0],
},
"topologyHint": {"faceIds": [201], "bodyIndex": 2, "faceOrdinal": 2},
"backendCommandCandidates": [
{"operation": "change_hole_diameter", "enabled": True, "parameterFields": {"diameter": 0.5}},
{"operation": "move_hole_axis", "enabled": True, "parameterFields": {"center": [5.0, 0.0, 0.0]}},
],
"rawLimitations": [],
},
{
"backendId": "hole:pattern-c",
"objectType": "hole",
"geometry": {
"diameter": 0.5,
"center": [10.0, 0.0, 0.0],
"axis": [0.0, 0.0, 1.0],
},
"topologyHint": {"faceIds": [202], "bodyIndex": 2, "faceOrdinal": 3},
"backendCommandCandidates": [
{"operation": "change_hole_diameter", "enabled": True, "parameterFields": {"diameter": 0.5}},
{"operation": "move_hole_axis", "enabled": True, "parameterFields": {"center": [10.0, 0.0, 0.0]}},
],
"rawLimitations": [],
},
{
"backendId": "face:9",
"objectType": "face",
"geometry": {
"surfaceType": "plane",
"center": [0.0, 0.0, 10.0],
"normal": [0.0, 0.0, 1.0],
},
"topologyHint": {"faceIds": [9]},
"backendCommandCandidates": [
{"operation": "pull_face_offset", "enabled": True, "parameterFields": {"distance": 0.0}},
],
"rawLimitations": [],
},
{
"backendId": "body:8/face:5",
"objectType": "face",
"geometry": {
"surfaceType": "cylinder",
"center": [0.5, 1.0, 9.5],
"axis": [0.0, 1.0, 0.0],
"radius": 0.25,
},
"topologyHint": {"bodyIndex": 8, "faceOrdinal": 5, "globalFaceOrdinal": 85},
"backendCommandCandidates": [],
"rawLimitations": [],
},
{
"backendId": "body:8/face:16",
"objectType": "face",
"geometry": {
"surfaceType": "cylinder",
"center": [0.5, 1.0, 9.5],
"axis": [0.0, 1.0, 0.0],
"radius": 0.25,
},
"topologyHint": {"bodyIndex": 8, "faceOrdinal": 16, "globalFaceOrdinal": 96},
"backendCommandCandidates": [],
"rawLimitations": [],
},
{
"backendId": "round:1",
"objectType": "round",
"geometry": {"radius": 1.0},
"backendCommandCandidates": [
{"operation": "change_round_radius", "enabled": True},
{"operation": "delete_round_or_chamfer", "enabled": True},
],
"rawLimitations": [],
},
{
"backendId": "slot:1",
"objectType": "slot",
"geometry": {"width": 2.0, "depth": 1.5, "center": [1.0, 2.0, 3.0]},
"topologyHint": {"faceIds": [30, 31, 32], "bodyIndex": 0, "faceOrdinal": 30},
"backendCommandCandidates": [
{"operation": "change_slot_width", "enabled": True},
{"operation": "move_slot", "enabled": True, "parameterFields": {"center": [1.0, 2.0, 3.0]}},
],
"rawLimitations": [],
},
{
"backendId": "boss:1",
"objectType": "cylindrical_boss",
"geometry": {"diameter": 3.0, "height": 4.0, "center": [0.0, 0.0, 2.0]},
"topologyHint": {"faceIds": [50, 51, 52], "bodyIndex": 0, "faceOrdinal": 50},
"backendCommandCandidates": [
{"operation": "change_boss_height", "enabled": True},
{"operation": "move_boss", "enabled": True, "parameterFields": {"center": [0.0, 0.0, 2.0]}},
],
"rawLimitations": [],
},
{
"backendId": "chamfer:1",
"objectType": "chamfer",
"geometry": {"distance": 0.8},
"backendCommandCandidates": [{"operation": "change_chamfer_distance", "enabled": True}],
"rawLimitations": [],
},
{
"backendId": "pattern:1",
"objectType": "linear_pattern",
"geometry": {"spacing": 5.0, "instanceCenter": [0.0, 5.0, 0.0]},
"backendCommandCandidates": [{"operation": "change_pattern_spacing", "enabled": True}],
"rawLimitations": [],
},
{
"backendId": "shell:1",
"objectType": "shell",
"geometry": {"thickness": 1.2},
"backendCommandCandidates": [{"operation": "change_shell_thickness", "enabled": True}],
"rawLimitations": [],
},
],
}
cache = map_scdm_raw_features(raw)
edge_signature = geometry_signature(
{
"backendId": "edge:1",
"objectType": "edge",
"geometry": {
"curveType": "Circle",
"length": 3.14,
"startPoint": [0.0, 0.0, 0.0],
"endPoint": [1.0, 0.0, 0.0],
"midPoint": [0.5, 0.0, 0.0],
"radius": 0.5,
"center": [0.5, 0.5, 0.0],
"axis": [0.0, 0.0, 1.0],
},
"topologyHint": {
"bodyIndex": 0,
"edgeOrdinal": 7,
"globalEdgeOrdinal": 9,
"adjacentFaceCount": 2,
"adjacentFaceOrdinals": [3, 4],
},
}
)
_assert(edge_signature.get("curveType") == "Circle", f"Edge curve type should be preserved: {edge_signature}")
_assert(edge_signature.get("length") == 3.14, f"Edge length should be preserved: {edge_signature}")
_assert(edge_signature.get("startPoint") == [0.0, 0.0, 0.0], f"Edge start point should be preserved: {edge_signature}")
_assert(edge_signature.get("adjacentFaceOrdinals") == [3, 4], f"Edge adjacent faces should be preserved: {edge_signature}")
_assert(cache.get("modelFingerprint") == "abc123", f"model fingerprint should be copied: {cache}")
_assert(cache.get("backendVersion") == "v222", f"backend version should be copied: {cache}")
_assert({"hole.diameter", "hole.position"} <= _capability_keys(cache, "hole:1"), f"hole caps missing: {cache}")
objects = cache.get("objects")
_assert(isinstance(objects, list), f"cache objects should be a list: {cache}")
hole_object = next((item for item in objects if isinstance(item, dict) and item.get("objectId") == "hole:hole:1"), None)
_assert(isinstance(hole_object, dict), f"hole object should be normalized: {cache}")
signature = hole_object.get("geometrySignature")
_assert(isinstance(signature, dict), f"hole signature should be present: {hole_object}")
_assert(signature.get("bodyIndex") == 0 and signature.get("faceOrdinal") == 12, f"SCDM script locator hints should be preserved: {signature}")
_assert({"face.offset"} <= _capability_keys(cache, "face:9"), f"face caps missing: {cache}")
cylinder_group = next(
(item for item in objects if isinstance(item, dict) and "cylindrical_group:body:8/face:5" in str(item.get("objectId") or "")),
None,
)
_assert(isinstance(cylinder_group, dict), f"split cylinder faces should produce a grouped SCDM object: {cache}")
cylinder_signature = cylinder_group.get("geometrySignature")
_assert(isinstance(cylinder_signature, dict), f"cylinder group should carry a geometry signature: {cylinder_group}")
_assert(cylinder_signature.get("faceOrdinals") == [5, 16], f"cylinder group should preserve all face ordinals: {cylinder_signature}")
_assert(len(cylinder_signature.get("scdmFaceLocators") or []) == 2, f"cylinder group should preserve SCDM face locators: {cylinder_signature}")
_assert({"hole.diameter", "hole.position", "feature.fill"} <= _capability_keys(cache, "cylindrical_group:body:8/face:5"), f"cylinder group caps missing: {cache}")
hole_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(94,), execution_ready=False)
hole_keys = {str(spec.get("scdm_capability_key")) for spec in hole_specs}
_assert({"hole.diameter", "hole.position"} <= hole_keys, f"SCDM cache should map selected hole Face to UI specs: {hole_specs}")
_assert(all(spec.get("enabled") is False for spec in hole_specs), f"SCDM specs should stay disabled before S5: {hole_specs}")
executable_hole_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(85,), execution_ready=True)
_assert(any(spec.get("enabled") is True for spec in executable_hole_specs), f"SCDM specs should enable once runner is ready: {executable_hole_specs}")
gated_hole_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(85,), execution_ready={"face.offset"})
_assert(all(spec.get("enabled") is False for spec in gated_hole_specs), f"capability gate should keep unverified hole edits disabled: {gated_hole_specs}")
face_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(9,), execution_ready=True)
_assert({str(spec.get("scdm_capability_key")) for spec in face_specs} == {"face.offset"}, f"Face cache should map to offset only: {face_specs}")
gated_face_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(9,), execution_ready={"face.offset"})
_assert(gated_face_specs and all(spec.get("enabled") is True for spec in gated_face_specs), f"capability gate should enable verified face offset: {gated_face_specs}")
_assert({"slot.position"} <= _capability_keys(cache, "slot:1"), f"slot.position should now be productized: {cache}")
slot_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(31,), execution_ready={"slot.position"})
_assert(
{str(spec.get("scdm_capability_key")) for spec in slot_specs} == {"slot.position"},
f"slot cache should expose only productized slot.position: {slot_specs}",
)
_assert(slot_specs and slot_specs[0].get("enabled") is True, f"slot.position should enable when runner gate is ready: {slot_specs}")
blocked_slot_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(31,), execution_ready={"face.offset"})
_assert(blocked_slot_specs and all(spec.get("enabled") is False for spec in blocked_slot_specs), f"slot.position should honor runner gate: {blocked_slot_specs}")
_assert({"boss.position"} <= _capability_keys(cache, "boss:1"), f"boss.position should now be productized: {cache}")
boss_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(51,), execution_ready={"boss.position"})
_assert(
{str(spec.get("scdm_capability_key")) for spec in boss_specs} == {"boss.position"},
f"boss cache should expose only productized boss.position: {boss_specs}",
)
_assert(boss_specs and boss_specs[0].get("enabled") is True, f"boss.position should enable when runner gate is ready: {boss_specs}")
blocked_boss_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(51,), execution_ready={"slot.position"})
_assert(blocked_boss_specs and all(spec.get("enabled") is False for spec in blocked_boss_specs), f"boss.position should honor runner gate: {blocked_boss_specs}")
raw_without_local_ids = {
"schemaVersion": 1,
"backend": {"name": "SCDM", "version": "v222"},
"model": {"path": str(step_path), "fingerprint": "abc123", "unit": "mm"},
"objects": [
{
"backendId": "face:no-local-id",
"objectType": "face",
"geometry": {
"surfaceType": "plane",
"center": [0.0, 0.0, 0.01],
"axis": [0.0, 0.0, 1.0],
"planeOffset": 0.01,
},
"topologyHint": {"bodyIndex": 0, "faceOrdinal": 3},
"backendCommandCandidates": [
{"operation": "pull_face_offset", "enabled": True, "parameterFields": {"distance": 0.0}},
],
"rawLimitations": [],
},
],
}
enriched = attach_local_face_ids_to_scdm_cache(
map_scdm_raw_features(raw_without_local_ids),
[
{
"faceId": 9,
"surfaceType": "plane",
"center": [0.0, 0.0, 10.0],
"axis": [0.0, 0.0, 1.0],
"planeOffset": 10.0,
}
],
)
enriched_specs = property_specs_from_scdm_cache(enriched, selected_face_ids=(9,), execution_ready=True)
_assert(enriched_specs and enriched_specs[0].get("scdm_capability_key") == "face.offset", f"local Face IDs should be attached from geometry signatures: {enriched}")
enriched_group = attach_local_face_ids_to_scdm_cache(
map_scdm_raw_features(raw),
[
{
"faceId": 85,
"surfaceType": "cylinder",
"center": [0.5, 1.0, 9.5],
"axis": [0.0, 1.0, 0.0],
"radius": 0.25,
},
{
"faceId": 96,
"surfaceType": "cylinder",
"center": [0.5, 1.0, 9.5],
"axis": [0.0, 1.0, 0.0],
"radius": 0.25,
},
],
)
grouped_specs = property_specs_from_scdm_cache(enriched_group, selected_face_ids=(96,), execution_ready=True)
grouped_keys = {str(spec.get("scdm_capability_key")) for spec in grouped_specs}
_assert({"hole.diameter", "hole.position", "feature.fill"} <= grouped_keys, f"local Face IDs should attach to SCDM cylinder groups: {enriched_group}")
missing_command_raw = {
"schemaVersion": 1,
"backend": {"name": "SCDM", "version": "v222"},
"model": {"path": str(step_path), "fingerprint": "abc123", "unit": "mm"},
"diagnostics": {"availableCommands": [{"name": "OffsetFaces", "available": False}]},
"objects": [
{
"backendId": "face:no-offset-command",
"objectType": "face",
"geometry": {
"surfaceType": "plane",
"center": [0.0, 0.0, 1.0],
"axis": [0.0, 0.0, 1.0],
"planeOffset": 1.0,
},
"topologyHint": {"faceIds": [12]},
"backendCommandCandidates": [
{"operation": "pull_face_offset", "enabled": True, "parameterFields": {"distance": 0.0}},
],
"rawLimitations": [],
}
],
}
missing_command_specs = property_specs_from_scdm_cache(map_scdm_raw_features(missing_command_raw), selected_face_ids=(12,), execution_ready=True)
_assert(missing_command_specs and missing_command_specs[0].get("enabled") is False, f"missing SCDM command should disable capability: {missing_command_specs}")
_assert("OffsetFaces" in str(missing_command_specs[0].get("disabled_tip") or ""), f"disabled reason should name the missing SCDM command: {missing_command_specs}")
diagnostics = cache.get("diagnostics")
_assert(isinstance(diagnostics, dict), f"cache diagnostics should be present: {cache}")
raw_summary = diagnostics.get("raw_summary")
_assert(isinstance(raw_summary, dict), f"SCDM raw summary should be preserved in cache diagnostics: {cache}")
_assert(raw_summary.get("faceCount") is None or isinstance(raw_summary.get("faceCount"), int), f"raw summary should stay JSON-like: {raw_summary}")
backend_commands = diagnostics.get("backend_commands")
_assert(isinstance(backend_commands, list), f"SCDM backend command inventory should be preserved: {cache}")
command_names = {str(item.get("name")) for item in backend_commands if isinstance(item, dict)}
_assert({"StandardHoles", "ConstantRound"} <= command_names, f"backend command names should be available in diagnostics: {backend_commands}")
face_adjacency = diagnostics.get("face_adjacency")
_assert(isinstance(face_adjacency, list) and face_adjacency, f"SCDM Face adjacency should be preserved in cache diagnostics: {cache}")
edge_summary = diagnostics.get("edge_geometry_summary")
_assert(isinstance(edge_summary, dict) and edge_summary.get("circularEdgeCount") == 4, f"SCDM Edge geometry summary should be preserved: {cache}")
feature_inventory = diagnostics.get("feature_inventory")
_assert(
isinstance(feature_inventory, dict)
and feature_inventory.get("objectTypeCounts", {}).get("hole") == 1
and feature_inventory.get("operationCounts", {}).get("change_slot_width") == 1,
f"SCDM feature inventory should be preserved: {cache}",
)
geometry_hints = diagnostics.get("geometry_candidate_hints")
_assert(isinstance(geometry_hints, list) and geometry_hints, f"SCDM structural candidate hints should be produced: {cache}")
hint_keys = {str(item.get("capabilityKey")) for item in geometry_hints if isinstance(item, dict)}
_assert(
{"slot.width", "boss.height", "round.radius", "chamfer.distance", "pattern.spacing", "shell.thickness"} <= hint_keys,
f"S7 geometry hints should cover planned feature families: {hint_keys}",
)
derived_candidates = diagnostics.get("derived_feature_candidates")
_assert(isinstance(derived_candidates, list) and derived_candidates, f"repeated holes should produce derived S7 candidates: {cache}")
pattern_candidate = next((item for item in derived_candidates if isinstance(item, dict) and item.get("objectType") == "linear_pattern"), None)
_assert(isinstance(pattern_candidate, dict), f"linear pattern candidate should be derived: {derived_candidates}")
pattern_signature = pattern_candidate.get("geometrySignature")
_assert(isinstance(pattern_signature, dict), f"linear pattern should keep a geometry signature: {pattern_candidate}")
_assert(pattern_signature.get("spacing") == 5.0, f"linear pattern spacing should be preserved: {pattern_signature}")
_assert(pattern_signature.get("instanceCount") == 3, f"linear pattern count should be preserved: {pattern_signature}")
_assert(pattern_signature.get("axis") == [1.0, 0.0, 0.0], f"linear pattern axis should be preserved: {pattern_signature}")
_assert(len(pattern_signature.get("instanceCenters") or []) == 3, f"linear pattern centers should be preserved: {pattern_signature}")
not_productized = diagnostics.get("discovered_not_productized")
_assert(isinstance(not_productized, list) and not_productized, f"round candidate should stay diagnostic only: {cache}")
planned = diagnostics.get("planned_not_productized")
_assert(isinstance(planned, list), f"planned diagnostics should be present: {cache}")
planned_keys = {str(item.get("capabilityKey")) for item in planned if isinstance(item, dict)}
expected_planned = {
"slot.width",
"slot.depth",
"boss.height",
"boss.diameter",
"round.radius",
"feature.delete_round_or_chamfer",
"chamfer.distance",
"pattern.spacing",
"pattern.instance_position",
"shell.thickness",
}
_assert(expected_planned <= planned_keys, f"S7 planned capabilities should be diagnosed but not productized: {planned_keys}")
raw_file = root / "raw.json"
cache_file = root / "cache.json"
write_json(raw_file, raw)
from_file = map_scdm_raw_features_file(raw_file, cache_file)
_assert(cache_file.is_file(), "cache file should be written")
_assert(from_file.get("objects") == read_json(cache_file).get("objects"), "file mapper should match in-memory mapper")
print("scdm probe pipeline ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+169
View File
@@ -0,0 +1,169 @@
from __future__ import annotations
import sys
import tempfile
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.scdm_result_validator import ( # noqa: E402
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,
)
def _assert(condition: bool, message: str) -> None:
if not condition:
raise AssertionError(message)
def _hole(object_id: str, face_id: int, *, diameter: float, center: tuple[float, float, float]) -> dict[str, object]:
return {
"objectId": object_id,
"objectType": "hole",
"geometrySignature": {
"objectType": "hole",
"faceIds": [face_id],
"surfaceType": "cylinder",
"center": list(center),
"axis": [0.0, 0.0, 1.0],
"diameter": diameter,
},
"capabilities": [
{"key": "hole.diameter", "currentValue": diameter},
{"key": "hole.position", "currentValue": list(center)},
],
}
def _cache(*objects: dict[str, object]) -> dict[str, object]:
return {
"schemaVersion": 1,
"source": "SCDM",
"objects": list(objects),
"diagnostics": {},
}
def _cache_with_summary(summary: dict[str, int], *objects: dict[str, object]) -> dict[str, object]:
cache = _cache(*objects)
cache["diagnostics"] = {"raw_summary": dict(summary)}
return cache
def main() -> int:
with tempfile.TemporaryDirectory(prefix="step_editor_scdm_validate_") as temp:
root = Path(temp)
output_step = root / "result.step"
output_step.write_text("ISO-10303-21;\nEND-ISO-10303-21;\n", encoding="utf-8")
before = _cache(
_hole("hole:85", 85, diameter=0.5, center=(0.5, 1.0, 9.5)),
_hole("hole:87", 87, diameter=0.5, center=(2.0, 1.0, 9.5)),
)
after = _cache(
_hole("hole:90", 90, diameter=0.75, center=(0.5, 1.0, 9.5)),
_hole("hole:91", 91, diameter=0.5, center=(2.0, 1.0, 9.5)),
)
before_signature = before["objects"][0]["geometrySignature"] # type: ignore[index]
match = match_scdm_object_by_signature(before_signature, after, capability_key="hole.diameter")
_assert(match.get("status") == "unique", f"changed diameter should still match by center/axis/type: {match}")
_assert(match.get("object", {}).get("objectId") == "hole:90", f"wrong match: {match}")
ok = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=before_signature,
before_cache=before,
after_cache=after,
capability_key="hole.diameter",
expected_target=0.75,
edited_object_id="hole:85",
brep_validator=lambda path: {"ok": path.is_file(), "reason": "ok"},
)
_assert(ok.get("ok") is True, f"validated edit should pass: {ok}")
_assert(ok.get("targetCheck", {}).get("ok") is True, f"target diameter should be checked: {ok}")
_assert(ok.get("topologyCheck", {}).get("ok") is True, f"unchanged objects should be checked: {ok}")
drift_after = _cache(_hole("hole:90", 90, diameter=0.75, center=(0.5, 1.0, 9.5)))
drift = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=before_signature,
before_cache=before,
after_cache=drift_after,
capability_key="hole.diameter",
expected_target=0.75,
edited_object_id="hole:85",
)
_assert(drift.get("ok") is False and drift.get("reason") == "unexpected-object-drift", f"missing unrelated hole should fail: {drift}")
direct_drift = check_scdm_unedited_objects(before, drift_after, edited_object_id="hole:85", edited_signature=before_signature)
_assert(direct_drift.get("ok") is False and direct_drift.get("checked") == 1, f"direct drift check should inspect one unedited object: {direct_drift}")
mismatch = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=before_signature,
after_cache=after,
capability_key="hole.diameter",
expected_target=0.9,
)
_assert(mismatch.get("ok") is False and mismatch.get("reason") == "target-mismatch", f"wrong target should fail: {mismatch}")
missing = validate_scdm_edit_result({"ok": True, "output_step": str(root / "missing.step")})
_assert(missing.get("ok") is False and missing.get("reason") == "missing-output-step", f"missing result STEP should fail: {missing}")
mapping = build_scdm_id_mapping(before, after, capability_key="hole.diameter")
_assert(mapping.get("faceIdMap") == {85: 90, 87: 91}, f"face IDs should remap through signatures: {mapping}")
rewritten = rewrite_scdm_relation_formula_ids("Face87.直径 = Face85.半径", mapping)
_assert(rewritten == "Face91.直径 = Face90.半径", f"formula IDs should follow SCDM remap: {rewritten}")
position_after = _cache(_hole("hole:91", 91, diameter=0.5, center=(2.0, 1.0, 6.0)))
position_check = check_scdm_target(position_after["objects"][0], capability_key="hole.position", expected_target=[2.0, 1.0, 6.0])
_assert(position_check.get("ok") is True, f"position target should pass: {position_check}")
summary_before = _cache_with_summary(
{"bodyCount": 13, "objectCount": 554, "faceCount": 158, "edgeCount": 396},
_hole("hole:85", 85, diameter=0.5, center=(0.5, 1.0, 9.5)),
)
summary_after_ok = _cache_with_summary(
{"bodyCount": 13, "objectCount": 550, "faceCount": 157, "edgeCount": 390},
_hole("hole:90", 90, diameter=0.75, center=(0.5, 1.0, 9.5)),
)
summary_ok = check_scdm_summary_delta(summary_before, summary_after_ok, capability_key="hole.diameter")
_assert(summary_ok.get("ok") is True, f"small summary changes should pass: {summary_ok}")
summary_after_bad = _cache_with_summary(
{"bodyCount": 13, "objectCount": 80, "faceCount": 20, "edgeCount": 45},
_hole("hole:90", 90, diameter=0.75, center=(0.5, 1.0, 9.5)),
)
summary_bad = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=summary_before["objects"][0]["geometrySignature"], # type: ignore[index]
before_cache=summary_before,
after_cache=summary_after_bad,
capability_key="hole.diameter",
expected_target=0.75,
)
_assert(summary_bad.get("ok") is False and summary_bad.get("reason") == "summary-drift", f"large summary drift should fail: {summary_bad}")
summary_fill = check_scdm_summary_delta(summary_before, summary_after_bad, capability_key="feature.fill")
_assert(summary_fill.get("ok") is None and summary_fill.get("reason") == "skipped-command-feature", f"fill should skip summary count guard: {summary_fill}")
ambiguous_after = _cache(
_hole("hole:100", 100, diameter=0.75, center=(0.5, 1.0, 9.5)),
_hole("hole:101", 101, diameter=0.75, center=(0.5, 1.0, 9.5)),
)
ambiguous = match_scdm_object_by_signature(before_signature, ambiguous_after, capability_key="hole.diameter")
_assert(ambiguous.get("status") == "multiple", f"ambiguous matches should be reported: {ambiguous}")
print("scdm result validator ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+175
View File
@@ -0,0 +1,175 @@
from __future__ import annotations
import sys
import tempfile
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.scdm_backend import ScdmBackendInfo, save_scdm_backend_cache # noqa: E402
from step_editor.scdm_status import cached_scdm_backend_payload, summarize_scdm_capability_progress, summarize_scdm_runtime # noqa: E402
def _assert(condition: bool, message: str) -> None:
if not condition:
raise AssertionError(message)
def main() -> int:
empty = summarize_scdm_runtime(cache_state="empty")
_assert(empty.get("backendReady") is False, f"empty backend should not be ready: {empty}")
_assert("未配置" in str(empty.get("headline")), f"empty headline should be clear: {empty}")
_assert("导入 STEP" in str(empty.get("detail")), f"empty detail should explain next step: {empty}")
backend = ScdmBackendInfo(
path=Path("D:/softwaresInstallDir/ANSYS Inc/v222/SCDM/SpaceClaim.exe"),
source="common:D:/softwaresInstallDir/ANSYS Inc",
version="v222",
verified_at="2026-08-18T00:00:00Z",
run_script_ok=True,
license_ok=True,
)
feature_cache = {
"objects": [
{"objectId": "face:1", "capabilities": [{"key": "face.offset"}]},
{"objectId": "hole:1", "capabilities": [{"key": "hole.diameter"}, {"key": "hole.position"}]},
]
}
ready = summarize_scdm_runtime(backend=backend, cache_state="ready", feature_cache=feature_cache)
_assert(ready.get("backendReady") is True, f"backend should be ready: {ready}")
_assert("已配置 v222" in str(ready.get("headline")), f"version should be visible: {ready}")
_assert("常见安装目录" in str(ready.get("headline")), f"source should be product text: {ready}")
_assert(ready.get("objectCount") == 2 and ready.get("capabilityCount") == 3, f"cache counts should be summarized: {ready}")
_assert("识别缓存已就绪" in str(ready.get("detail")), f"ready detail should be explicit: {ready}")
_assert("/RunScript:可用" in str(ready.get("tooltip")), f"tooltip should include /RunScript status: {ready}")
running = summarize_scdm_runtime(backend=backend.to_cache(), cache_state="running")
_assert("正在后台识别" in str(running.get("detail")), f"running state should explain background probe: {running}")
failed = summarize_scdm_runtime(backend=backend.to_cache(), cache_state="failed", cache_message="SpaceClaim.exe was not found.")
_assert("识别未启用" in str(failed.get("detail")), f"failed state should be clear: {failed}")
_assert("已有能力" in str(failed.get("detail")), f"failed state should explain fallback: {failed}")
disabled = summarize_scdm_runtime(backend={"disabled": True}, cache_state="ready", feature_cache=feature_cache)
_assert(disabled.get("backendReady") is False, f"disabled backend should not be ready: {disabled}")
_assert("已关闭" in str(disabled.get("headline")), f"disabled state should be clear: {disabled}")
_assert("不会启动" in str(disabled.get("detail")), f"disabled detail should explain behavior: {disabled}")
stale = summarize_scdm_runtime(backend=backend.to_cache(), cache_state="stale", cache_message="模型已重新加载,SCDM cache 已失效。")
_assert("失效" in str(stale.get("detail")), f"stale state should be visible: {stale}")
progress_cache = {
"objects": [
{
"objectId": "face:1",
"capabilities": [{"key": "face.offset", "displayName": "偏移"}],
},
{
"objectId": "hole:1",
"capabilities": [
{"key": "hole.diameter", "displayName": "直径", "blockReason": "SCDM 当前脚本环境缺少 OffsetFaces 命令。"},
{"key": "hole.position", "displayName": "位置"},
{"key": "feature.fill", "displayName": "填孔/删除小特征"},
],
},
],
"diagnostics": {
"face_adjacency": [
{"bodyIndex": 0, "faceOrdinals": [1, 2], "edgeCount": 1},
{"bodyIndex": 0, "faceOrdinals": [2, 3], "edgeCount": 2},
],
"edge_geometry_summary": {
"totalEdgeCount": 12,
"edgeKindCounts": {"linear": 8, "circular": 4},
"circularEdgeCount": 4,
"circularRadiusBuckets": [{"radius": "0.25", "count": 4}],
},
"feature_inventory": {
"objectTypeCounts": {"face": 4, "hole": 2, "edge": 12, "slot": 1},
"surfaceTypeCounts": {"plane": 4, "cylinder": 3},
"operationCounts": {"pull_face_offset": 4, "change_hole_diameter": 2, "change_slot_width": 1},
},
"geometry_candidate_hints": [
{
"capabilityKey": "boss.height",
"displayName": "凸台高度",
"evidenceCount": 3,
"confidence": "low",
},
{
"capabilityKey": "pattern.spacing",
"displayName": "阵列间距",
"evidenceCount": 2,
"confidence": "low",
},
],
"derived_feature_candidates": [
{
"objectId": "derived:linear_pattern:hole-a|hole-b|hole-c",
"objectType": "linear_pattern",
"geometrySignature": {"spacing": 5.0, "instanceCount": 3},
}
],
"planned_not_productized": [
{"capabilityKey": "slot.width", "objectType": "slot"},
{"capabilityKey": "slot.width", "objectType": "slot"},
{"capabilityKey": "round.radius", "objectType": "round"},
],
"discovered_not_productized": [
{"objectType": "mystery_feature"},
{"objectType": "mystery_feature"},
],
},
}
progress = summarize_scdm_capability_progress(
feature_cache=progress_cache,
execution_ready={"face.offset", "hole.diameter", "hole.position", "slot.position", "boss.position"},
)
summary = progress.get("summary")
_assert(isinstance(summary, dict), f"capability progress should include summary: {progress}")
_assert(summary.get("productized") == 6, f"productized capability count should include S5 plus Move-based S7 entries: {summary}")
_assert(summary.get("runnerReady") == 5, f"runner-ready capability count should honor UI gate: {summary}")
_assert(summary.get("executableCapabilities") == 2, f"blocked/ungated capabilities should not be executable: {summary}")
_assert(summary.get("plannedDetected") == 3, f"planned S7 detections should be counted: {summary}")
_assert(summary.get("discoveredNotProductized") == 2, f"unknown discoveries should be counted: {summary}")
_assert(summary.get("faceAdjacency") == 2 and summary.get("circularEdges") == 4, f"probe topology evidence should be counted: {summary}")
_assert(
summary.get("inventoryObjectTypes") == 19 and summary.get("inventoryOperationCandidates") == 7,
f"probe feature inventory should be counted: {summary}",
)
_assert(summary.get("geometryHints") == 5, f"geometry candidate hints should be counted: {summary}")
_assert(summary.get("derivedFeatureCandidates") == 1, f"derived S7 candidate count should be visible: {summary}")
productized_lines = "\n".join(str(line) for line in progress.get("productizedLines", []))
planned_lines = "\n".join(str(line) for line in progress.get("plannedLines", []))
evidence_lines = "\n".join(str(line) for line in progress.get("probeEvidence", {}).get("lines", []))
_assert("偏移:已开放" in productized_lines, f"open SCDM capability should be visible: {productized_lines}")
_assert("直径:已开放但被后端阻止" in productized_lines, f"blocked SCDM capability should be explicit: {productized_lines}")
_assert("填孔/删除小特征:已识别待执行器" in productized_lines, f"recognized but ungated capability should be visible: {productized_lines}")
_assert("槽宽:已识别待验证" in planned_lines, f"planned S7 candidate should be visible: {planned_lines}")
_assert("凸台高度:几何证据待分类" in planned_lines, f"geometry-only S7 hint should be visible: {planned_lines}")
_assert("Face 邻接 2 组" in evidence_lines and "圆边 4 条" in evidence_lines, f"probe evidence lines should be readable: {evidence_lines}")
_assert("对象分布" in evidence_lines and "命令候选分布" in evidence_lines, f"probe inventory lines should be readable: {evidence_lines}")
_assert("几何候选 凸台高度:3" in evidence_lines, f"probe geometry hint lines should be readable: {evidence_lines}")
_assert("linear_pattern:1" in evidence_lines, f"derived S7 candidate lines should be readable: {evidence_lines}")
with tempfile.TemporaryDirectory(prefix="step_editor_scdm_status_") as temp:
root = Path(temp)
fake_exe = root / "ANSYS Inc" / "v222" / "SCDM" / "SpaceClaim.exe"
fake_exe.parent.mkdir(parents=True, exist_ok=True)
fake_exe.write_text("fake", encoding="utf-8")
saved_backend = ScdmBackendInfo(path=fake_exe, source="manual", version="v222", run_script_ok=True, license_ok=True)
save_scdm_backend_cache(saved_backend, project_root_override=root)
cached = cached_scdm_backend_payload(root)
_assert(isinstance(cached, dict), f"cached backend should load: {cached}")
_assert(str(cached.get("source")) == "manual", f"cached source should be preserved: {cached}")
print("scdm status summary ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())