feat: 推进SCDM-first后端接入和大模型编辑优化
This commit is contained in:
@@ -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",)),
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user