feat: 完善 SCDM-first 参数化编辑交付版

This commit is contained in:
2026-08-20 17:12:01 +08:00
parent 4e7877e05c
commit b4feab24d2
21 changed files with 3015 additions and 1729 deletions
@@ -295,6 +295,53 @@ def _verify_slot_summary(root: Path) -> None:
_assert(int(info.get("recognition_user_priority", 99)) == 30, f"slot should use slot priority: {info}")
def _verify_split_cylinder_slot_and_hole_guard() -> None:
geom_path = PROJECT_ROOT / "assets" / "models" / "geom_extract.step"
if geom_path.exists():
geom_model = StepModel.load(geom_path)
for slot_face_id in (1360, 1722):
slot_info = geom_model.feature_info(slot_face_id)
_assert(
"" in str(slot_info.get("feature_type") or "")
and "圆柱孔候选" not in str(slot_info.get("feature_type") or ""),
f"geom_extract Face{slot_face_id} should be treated as a slot/groove, not a cylindrical hole: {slot_info}",
)
_assert(
slot_info.get("slot_status") in {"candidate", "blocked"},
f"geom_extract Face{slot_face_id} should keep slot classification fields: {slot_info}",
)
_assert(
"封堵圆柱孔" not in str(slot_info.get("feature_edit_actions") or ""),
f"geom_extract Face{slot_face_id} should not expose cylindrical-hole suppress wording: {slot_info}",
)
_assert(
int(slot_info.get("recognition_user_priority", 99)) == 30,
f"geom_extract Face{slot_face_id} should use slot priority: {slot_info}",
)
through_hole_info = geom_model.feature_info(1591)
_assert(
through_hole_info.get("feature_type") == "圆柱孔候选",
f"geom_extract Face1591 should remain a cylindrical hole guard case: {through_hole_info}",
)
_assert(
int(through_hole_info.get("recognition_user_priority", 99)) == 20,
f"geom_extract Face1591 should keep hole priority: {through_hole_info}",
)
icepak_path = PROJECT_ROOT / "assets" / "models" / "ICEPAK-NATURAL.stp"
if icepak_path.exists():
icepak_model = StepModel.load(icepak_path)
hole_info = icepak_model.feature_info(87)
_assert(
hole_info.get("feature_type") == "圆柱孔候选",
f"ICEPAK Face87 is a split through-hole and should remain a hole: {hole_info}",
)
_assert(
int(hole_info.get("recognition_user_priority", 99)) == 20,
f"ICEPAK Face87 should keep hole priority: {hole_info}",
)
def _verify_boss_summary(root: Path) -> None:
path = root / "boss.step"
_write_boss_model(path)
@@ -574,6 +621,7 @@ def main() -> int:
_verify_quick_cylinder_recognition(root)
_verify_hole_summary(root)
_verify_slot_summary(root)
_verify_split_cylinder_slot_and_hole_guard()
_verify_boss_summary(root)
_verify_asitus_slot_boss_fillet_hints(root)
_verify_torus_summary(root)
+16 -8
View File
@@ -57,6 +57,9 @@ def _verify_readme_mentions(readme: str) -> None:
"[scdm_probe_job.json -> /RunScript 扫描 STEP]",
"[scdm_feature_cache.json -> 映射为本软件能力字典和中文参数]",
"[参数化建模 -> 多个目标值统一提交,不再每行一个操作按钮]",
"[公式输入 -> 支持 Face85.直径 = Face87.半径",
"[批量联动 -> 多条公式先求值成一组目标参数",
"[关系式管理 -> 公式启停、删除回滚、基础单位字面量 mm/cm/m、JSON 导入/导出已接",
"[Face 偏移 -> face.offset / OffsetFaces]",
"[槽宽 -> slot.width / OffsetFaces",
"[槽深 -> slot.depth / Move 或 OffsetFaces",
@@ -96,7 +99,7 @@ def _verify_readme_mentions(readme: str) -> None:
def _verify_roadmap_scope(readme: str) -> None:
start_marker = "SCDM-first 主路线"
end_marker = "└── 7. 交付与兜底边界"
end_marker = "└── 8. 交付与兜底边界"
start = readme.find(start_marker)
end = readme.find(end_marker)
_assert(start >= 0 and end > start, "README should contain a single SCDM-first roadmap before fallback boundary")
@@ -110,14 +113,19 @@ def _verify_roadmap_scope(readme: str) -> None:
"│ ├── [x] [scdm_probe_job.json -> /RunScript 扫描 STEP]",
"│ ├── [x] [scdm_feature_cache.json -> 映射为本软件能力字典和中文参数]",
"├── 2. 能力字典、参数表和用户入口",
"├── 3. SCDM 参数化建模执行",
"├── 4. 结果校验、回滚和 ID 续接",
"├── 5. 当前已开放或正在开放的 SCDM 能力",
"├── 3. 关系式与参数联动",
"[公式输入 -> 支持 Face85.直径 = Face87.半径 这类对象.参数表达式和补全]",
"[添加守门 -> 阻止自引用、重复目标、循环依赖和当前无可执行参数的公式]",
"[批量联动 -> 多条公式先求值成一组目标参数,再合并为一个参数化建模任务]",
"├── 4. SCDM 参数化建模执行",
"├── 5. 结果校验、回滚和 ID 续接",
"├── 6. 当前已开放或正在开放的 SCDM 能力",
"[阵列间距 -> pattern.spacing / Move,整体阵列保持中心不变并等距重排;安全范围由支撑面动态计算,不针对 Face92 写死]",
"[局部间距 -> pattern.segment_spacing / Move,按“FaceA-FaceB 间距”或“零件A-零件B 间距”修改相邻段;已支持固定前项移动后侧、固定后项移动前侧、两侧均分保持中心]",
"├── 6. 下一批只按 SCDM 能力适配",
"[阵列实例位置 -> pattern.instance_position]",
"[壳体厚度 -> shell.thickness,薄壁候选配对已进 cache",
"[局部间距 -> pattern.segment_spacing / Move,按“FaceA-FaceB 间距”或“零件A-零件B 间距”修改相邻段;已支持固定前项移动后侧、固定后项移动前侧、两侧均分保持中心、只移动前项、只移动后项]",
"[阵列实例位置 -> pattern.instance_position / Move,只移动当前阵列成员;不自动保持整体阵列等距,真实 STEP 回测待补]",
"[壳体厚度 -> shell.thickness / Move,薄壁两平面配对后固定一侧、移动另一侧;真实 STEP 回测待补]",
"├── 7. 下一批只按 SCDM 能力适配",
"[SCDM 之外的新能力 -> 等 SCDM 能力适配完再评估]",
)
for fragment in required_active_fragments:
_assert(fragment in active_scope, f"README active roadmap missing: {fragment}")
+463 -7
View File
@@ -37,6 +37,8 @@ if str(PROJECT_ROOT) not in sys.path:
from step_editor.widgets import NoWheelComboBox
from step_editor.relation_formulas import ObjectParameterRef, parse_relation_formula
from step_editor.scdm_feature_mapper import SCDM_FEATURE_CACHE_REVISION
from step_editor.scdm_schema import file_fingerprint
from step_editor.window_actions import WindowActionMixin
from step_editor.window_core import WindowCoreMixin
from step_editor.window_state import (
@@ -103,6 +105,8 @@ class _PropertyTableProbe(QWidget, WindowStateMixin):
"feature.delete_round_or_chamfer",
"pattern.spacing",
"pattern.segment_spacing",
"pattern.instance_position",
"shell.thickness",
}
layout = QVBoxLayout(self)
@@ -142,6 +146,9 @@ class _PropertyTableProbe(QWidget, WindowStateMixin):
self.relation_formula_input.textChanged.connect(self._on_relation_formula_input_changed)
layout.addWidget(self.relation_formula_input)
self.add_relation_formula_button = QPushButton()
self.import_relation_formula_button = QPushButton()
self.export_relation_formula_button = QPushButton()
self.toggle_relation_formula_button = QPushButton()
self.remove_relation_formula_button = QPushButton()
self.relation_formula_list = QListWidget()
self.face_width_input = QLineEdit()
@@ -1043,6 +1050,142 @@ def _assert_relation_formula_input_clickable_after_existing_formula() -> None:
_assert(probe.add_relation_formula_button.isEnabled(), "clickable input should allow adding another formula")
def _assert_relation_formula_can_toggle_without_deleting() -> None:
probe = _PropertyTableProbe()
probe._refresh_property_editor()
probe.relation_formula_input.setText("Face0.面内宽度 = Face0.面内长度 * 1.2")
probe.add_relation_formula()
for _index in range(12):
QApplication.processEvents()
if not getattr(probe, "relation_formula_replay_active", False):
break
_assert(len(probe.relation_formula_items) == 1, f"formula should exist before toggling: {probe.relation_formula_items}")
_assert(probe.relation_formula_list.count() == 1, "formula list should show the formula before toggling")
probe.relation_formula_list.setCurrentRow(0)
probe._update_relation_formula_buttons()
_assert(probe.toggle_relation_formula_button.isEnabled(), "selected formula should enable the toggle button")
_assert(probe.toggle_relation_formula_button.text() == "停用公式", f"enabled formula should offer disable action: {probe.toggle_relation_formula_button.text()}")
restore_calls = {"count": 0}
def _restore_base() -> bool:
restore_calls["count"] += 1
return True
probe.relation_formula_base_snapshot = {"baseline": True}
probe._restore_relation_formula_base_snapshot = _restore_base
probe.toggle_selected_relation_formula()
_assert(restore_calls["count"] == 1, "disabling the last active formula should restore the formula base snapshot")
_assert(probe.relation_formula_items[0].get("enabled") is False, f"formula should be disabled, not deleted: {probe.relation_formula_items}")
_assert("停用" in probe.relation_formula_list.item(0).text(), f"disabled formula should be visibly marked: {probe.relation_formula_list.item(0).text()}")
_assert(not getattr(probe, "relation_formula_replay_active", False), "disabling all formulas should not leave replay active")
probe.relation_formula_list.setCurrentRow(0)
probe._update_relation_formula_buttons()
_assert(probe.toggle_relation_formula_button.text() == "启用公式", f"disabled formula should offer enable action: {probe.toggle_relation_formula_button.text()}")
probe.toggle_selected_relation_formula()
for _index in range(12):
QApplication.processEvents()
if not getattr(probe, "relation_formula_replay_active", False):
break
_assert(probe.relation_formula_items[0].get("enabled") is True, f"formula should be re-enabled: {probe.relation_formula_items}")
_assert(str(probe.relation_formula_items[0].get("status")) in {"applied", "ready"}, f"enabled formula should return to a computable state: {probe.relation_formula_items}")
probe.relation_formula_list.setCurrentRow(0)
probe._update_relation_formula_buttons()
_assert(probe.toggle_relation_formula_button.text() == "停用公式", "re-enabled formula should offer disable action again")
def _assert_relation_formula_import_export() -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
root = Path(tmp_dir)
probe = _PropertyTableProbe()
probe.relation_formula_items = [
{
"id": 7,
"text": "Face87.直径 = Face85.半径",
"enabled": True,
"status": "applied",
"message": "已修改模型。",
}
]
export_path = root / "relation_formulas.json"
_assert(probe.export_relation_formulas(export_path), "relation formula export should succeed")
payload = json.loads(export_path.read_text(encoding="utf-8"))
_assert(payload.get("schema") == "python-occt.relation-formulas.v1", f"export schema missing: {payload}")
formulas = payload.get("formulas")
_assert(isinstance(formulas, list) and len(formulas) == 1, f"export should contain one formula: {payload}")
_assert(
formulas[0].get("text") == "Face87.直径 = Face85.半径" and formulas[0].get("enabled") is True,
f"exported formula row is wrong: {formulas}",
)
import_path = root / "import_formulas.json"
import_path.write_text(
json.dumps(
{
"formulas": [
{"text": "Face87.直径 = 10mm", "enabled": True},
{"text": "Face85.直径 = Face87.半径", "enabled": False},
]
},
ensure_ascii=False,
),
encoding="utf-8",
)
reapply_calls: list[dict[str, object]] = []
probe._start_relation_formula_reapply = lambda **kwargs: reapply_calls.append(dict(kwargs))
_assert(probe.import_relation_formulas(import_path), "relation formula import should succeed")
_assert(len(probe.relation_formula_items) == 2, f"import should replace the formula set: {probe.relation_formula_items}")
_assert(probe.relation_formula_items[0].get("text") == "Face87.直径 = 10mm", f"import should normalize first formula: {probe.relation_formula_items}")
_assert(probe.relation_formula_items[1].get("enabled") is False, f"import should preserve disabled state: {probe.relation_formula_items}")
_assert(reapply_calls and reapply_calls[-1].get("reason") == "import", f"enabled imported formulas should trigger reapply: {reapply_calls}")
previous_items = [dict(item) for item in probe.relation_formula_items]
invalid_path = root / "invalid_formulas.json"
invalid_path.write_text(
json.dumps({"formulas": ["Face1.直径 = 1", "Face1.直径 = 2"]}, ensure_ascii=False),
encoding="utf-8",
)
_assert(not probe.import_relation_formulas(invalid_path), "invalid formula groups should be rejected")
_assert(probe.relation_formula_items == previous_items, "failed import should not alter the current formula set")
def _assert_scdm_first_holds_ambiguous_large_cylinders() -> None:
probe = _PropertyTableProbe()
probe.selected_kind = "feature"
probe.selected_face_id = 1722
probe.scdm_feature_cache = None
probe.scdm_feature_cache_state = "deferred"
probe._large_model_interaction_mode = lambda: True
def _cylinder_action_info() -> dict[str, object]:
return {
"surface": "cylinder",
"feature_guess": "hole/groove candidate",
"feature_type": "圆柱孔候选",
"diameter": 2.6,
"radius": 1.3,
"axis": (0.0, 1.0, 0.0),
"axis_point": (-83.0, -31.0, -116.35),
"part_id": 1,
"solid_id": 0,
}
probe.current_info_values = _cylinder_action_info()
probe._selected_action_info = _cylinder_action_info
probe._refresh_property_editor()
labels = [
probe.property_table.item(row, PROPERTY_LABEL_COLUMN).text()
for row in range(probe.property_table.rowCount())
if probe.property_table.item(row, PROPERTY_LABEL_COLUMN) is not None
]
_assert(not labels, f"SCDM-first large-model cylinder should not expose local hole parameters while cache is deferred: {labels}")
_assert(
"不开放本地兜底孔/槽参数" in str(getattr(probe, "scdm_selection_status_message", "")),
f"SCDM-first hold should explain why local cylinder specs are hidden: {getattr(probe, 'scdm_selection_status_message', '')}",
)
def _assert_relation_formula_input_is_selection_independent() -> None:
probe = _RelationFormulaEventProbe()
probe.relation_formula_input.installEventFilter(probe)
@@ -1577,6 +1720,191 @@ def _assert_scdm_selection_diagnostics() -> None:
_assert("偏移" in str(info.get("scdm_selection_enabled_capabilities")), f"SCDM enabled capability diagnostic missing: {info}")
def _assert_scdm_parameter_table_shows_only_enabled_specs() -> None:
probe = _PropertyTableProbe()
probe.selected_kind = "face"
probe.selected_face_id = 31
probe.scdm_feature_cache_state = "ready"
probe.scdm_feature_cache = {
"objects": [
{
"objectId": "slot:31",
"objectType": "slot",
"geometrySignature": {"faceIds": [31], "objectType": "slot"},
"capabilities": [
{
"key": "slot.width",
"displayName": "槽宽",
"currentValue": 2.0,
"valueKind": "positive",
"defaultIntent": "改槽宽",
"backendOperation": "change_slot_width",
"postCheck": "target_slot_width",
},
{
"key": "slot.depth",
"displayName": "槽深",
"currentValue": 1.5,
"valueKind": "positive",
"defaultIntent": "改槽深",
"backendOperation": "change_slot_depth",
"postCheck": "target_slot_depth",
},
],
}
]
}
def labels() -> list[str]:
return [
probe.property_table.item(row, PROPERTY_LABEL_COLUMN).text()
for row in range(probe.property_table.rowCount())
if probe.property_table.item(row, PROPERTY_LABEL_COLUMN) is not None
]
probe.scdm_edit_runner_ready = set()
probe._refresh_property_editor()
disabled_labels = labels()
_assert("槽宽" not in disabled_labels and "槽深" not in disabled_labels, f"disabled SCDM specs should stay out of the parameter table: {disabled_labels}")
_assert(
"当前能力未开放" in str(probe.current_info_values.get("scdm_selection_status") or ""),
f"disabled SCDM specs should remain visible as a diagnostic reason: {probe.current_info_values}",
)
probe.scdm_edit_runner_ready = {"slot.width", "slot.depth"}
probe._refresh_property_editor()
enabled_labels = labels()
_assert(enabled_labels == ["槽宽", "槽深"], f"enabled SCDM specs should replace local fallback rows: {enabled_labels}")
_assert(
all(bool(spec.get("enabled")) for spec in probe.property_editor_specs),
f"property table should only hold executable SCDM rows: {probe.property_editor_specs}",
)
def _assert_ambiguous_slot_empty_state_explains_missing_params() -> None:
probe = _PropertyTableProbe()
probe.selected_kind = "feature"
probe.selected_face_id = 1362
probe.scdm_feature_cache_state = "ready"
probe.scdm_feature_cache = {"objects": []}
slot_candidate_info = {
"surface": "cylinder",
"feature_guess": "hole/groove candidate",
"feature_type": "圆柱孔/槽候选",
"recognition_confidence": "medium",
"recognition_score": 62,
"diameter": 2.6,
"radius": 1.3,
"angular_span": 3.141592653589793,
"part_id": 1,
"solid_id": 0,
}
probe.current_info_values = dict(slot_candidate_info)
probe._selected_action_info = lambda: dict(slot_candidate_info)
probe._refresh_property_editor()
labels = [
probe.property_table.item(row, PROPERTY_LABEL_COLUMN).text()
for row in range(probe.property_table.rowCount())
if probe.property_table.item(row, PROPERTY_LABEL_COLUMN) is not None
]
_assert("槽宽" not in labels and "槽深" not in labels, f"ambiguous slot candidates should not expose fake slot params: {labels}")
_assert("可修改参数" in labels, f"ambiguous slot should keep an empty-state row: {labels}")
status = str(probe.current_info_values.get("scdm_selection_status") or "")
_assert(
"圆柱孔/槽候选" in status and "证据完整" in status,
f"ambiguous slot empty state should explain the missing editable evidence: {probe.current_info_values}",
)
def _assert_scdm_round_delete_conflict_filtered_for_blind_pocket() -> None:
def round_delete_cache(face_id: int) -> dict[str, object]:
return {
"objects": [
{
"objectId": f"round:{face_id}",
"objectType": "round",
"geometrySignature": {"faceIds": [face_id], "surfaceType": "cylinder", "radius": 1.3},
"capabilities": [
{
"key": "feature.delete_round_or_chamfer",
"displayName": "删除圆角/倒角",
"currentValue": 1,
"valueKind": "command",
"editable": True,
"defaultIntent": "删除圆角/倒角并补面",
"backendOperation": "delete_round_or_chamfer",
"postCheck": "target_feature_removed",
}
],
}
]
}
probe = _PropertyTableProbe()
probe.selected_kind = "feature"
probe.selected_face_id = 1360
probe.scdm_feature_cache_state = "ready"
probe.scdm_edit_runner_ready = {"feature.delete_round_or_chamfer"}
probe.scdm_feature_cache = round_delete_cache(1360)
pocket_info = {
"surface": "cylinder",
"feature_guess": "hole/groove candidate",
"feature_type": "圆柱孔/槽候选",
"blind_split_cylindrical_pocket": True,
"diameter": 2.6,
"radius": 1.3,
"part_id": 1,
"solid_id": 0,
}
probe.current_info_values = dict(pocket_info)
probe._selected_action_info = lambda: dict(pocket_info)
probe._refresh_property_editor()
labels = [
probe.property_table.item(row, PROPERTY_LABEL_COLUMN).text()
for row in range(probe.property_table.rowCount())
if probe.property_table.item(row, PROPERTY_LABEL_COLUMN) is not None
]
_assert("删除圆角/倒角" not in labels, f"blind pocket should not expose round/chamfer delete: {labels}")
_assert(not labels, f"blind pocket SCDM conflict should not fall back to misleading local rows: {labels}")
_assert(
probe.current_info_values.get("scdm_selection_capability_count") == 0,
f"filtered SCDM conflict should not remain in diagnostic capability count: {probe.current_info_values}",
)
_assert(
probe.current_info_values.get("scdm_selection_blocked_count") == 0,
f"filtered SCDM conflict should not remain in blocked diagnostics: {probe.current_info_values}",
)
_assert(
"已隐藏该冲突操作" in str(probe.current_info_values.get("scdm_selection_status") or ""),
f"filtered SCDM conflict should be explained in diagnostics: {probe.current_info_values}",
)
round_probe = _PropertyTableProbe()
round_probe.selected_kind = "feature"
round_probe.selected_face_id = 1722
round_probe.scdm_feature_cache_state = "ready"
round_probe.scdm_edit_runner_ready = {"feature.delete_round_or_chamfer"}
round_probe.scdm_feature_cache = round_delete_cache(1722)
round_info = {
"surface": "cylinder",
"feature_guess": "round/fillet candidate",
"feature_type": "圆角/倒圆候选",
"existing_fillet_status": "candidate",
"radius": 1.3,
"part_id": 1,
"solid_id": 0,
}
round_probe.current_info_values = dict(round_info)
round_probe._selected_action_info = lambda: dict(round_info)
round_probe._refresh_property_editor()
round_labels = [
round_probe.property_table.item(row, PROPERTY_LABEL_COLUMN).text()
for row in range(round_probe.property_table.rowCount())
if round_probe.property_table.item(row, PROPERTY_LABEL_COLUMN) is not None
]
_assert("删除圆角/倒角" in round_labels, f"real round candidates should keep SCDM delete capability: {round_labels}")
def _assert_solid_selection_does_not_expand_face_scdm_specs() -> None:
probe = _PropertyTableProbe()
probe.scdm_feature_cache_state = "ready"
@@ -1799,21 +2127,35 @@ def _assert_large_model_preload_stays_lightweight() -> None:
"STEP load should restore a matching SCDM disk cache before launching a new probe",
)
_assert(
"_defer_large_model_recognition_preloads" in loaded_body,
"large model loads should defer full external recognition preloads by default",
"_defer_post_import_recognition_preloads" in loaded_body
and "_start_asitus_hole_recognition_preload" not in loaded_body,
"ordinary STEP import should display the model first and defer external recognition until selection",
)
_assert(
"_start_scdm_probe_preload(force=pending_scdm_reload)" in loaded_body,
"_start_scdm_probe_preload(force=True)" in loaded_body,
"SCDM edit-result reloads should still be able to force cache refresh for validation",
)
restore_cache_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_restore_scdm_feature_cache_from_disk")
_assert(
"scdm_raw_features.json" not in restore_cache_body
and "attach_local_face_ids_to_scdm_cache" not in restore_cache_body
and "_scdm_local_face_signatures()" not in restore_cache_body,
"STEP import should not rebuild SCDM Face mappings from raw cache during the first display path",
)
preload_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_start_scdm_probe_preload")
_assert(
"_current_scdm_feature_cache_matches_loaded_step()" in preload_body,
"SCDM preload should skip relaunching SpaceClaim when a current cache is already installed",
)
_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",
"model=model" not in preload_body
and 'getattr(model, "scdm_local_face_signatures"' not in preload_body
and "getattr(model, 'scdm_local_face_signatures'" not in preload_body,
"SCDM preload worker must not keep or inspect the current UI StepModel across threads",
)
_assert(
"face_signatures=tuple(local_face_signatures)" in preload_body,
"SCDM preload should pass a copied local Face signature snapshot into the worker",
)
_assert(
"force: bool = False" in preload_body
@@ -1822,8 +2164,8 @@ def _assert_large_model_preload_stays_lightweight() -> None:
"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",
'builder = getattr(model, "scdm_local_face_signatures", None)' not in preload_body,
"SCDM preload should not build local Face signatures from the current UI model 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")
@@ -1888,6 +2230,15 @@ def _assert_large_model_selection_stays_lightweight() -> None:
"large_model_hover_disabled" in hover_body,
"large-model hover picking should be suppressed before the VTK picker runs",
)
select_feature_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "select_feature")
select_face_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "select_face")
scdm_on_demand_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_maybe_start_scdm_probe_for_selection")
_assert(
"_maybe_start_scdm_probe_for_selection" in select_feature_body
and "_maybe_start_scdm_probe_for_selection" in select_face_body
and "_start_scdm_probe_preload(force=True)" in scdm_on_demand_body,
"large-model object selection should trigger on-demand SCDM probing instead of relying on ambiguous local cylinder fallback",
)
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
@@ -1907,6 +2258,101 @@ def _assert_large_model_selection_stays_lightweight() -> None:
)
def _assert_vtk_interaction_stability_guards() -> None:
copy_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_copy_polydata_for_ui_thread")
_assert(
"DeepCopy" in copy_body,
"worker-built VTK polydata should be deep-copied before the UI renderer owns it",
)
render_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_render_window_safely")
_assert(
"_is_ui_thread" in render_body and "scene_rebuild_in_progress" in render_body,
"render requests should stay on the UI thread and skip non-forced renders during scene rebuilds",
)
window_core_text = (PROJECT_ROOT / "step_editor/window_core.py").read_text(encoding="utf-8")
window_state_text = (PROJECT_ROOT / "step_editor/window_state.py").read_text(encoding="utf-8")
direct_core_renders = window_core_text.count("render_window.Render()") - render_body.count("render_window.Render()")
_assert(
direct_core_renders == 0 and "self.render_window.Render()" not in window_state_text,
"window code should route all VTK render requests through _render_window_safely()",
)
pick_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_pick_actor_cell")
_assert(
"scene_rebuild_in_progress" in pick_body and "PickFromListOff" in pick_body,
"VTK picking should be disabled while actors/polydata are being replaced",
)
hover_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_update_hover_target")
_assert(
"scene_rebuild_in_progress" in hover_body,
"hover picking should be suppressed during scene rebuilds",
)
finish_scdm_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_finish_scdm_probe_preload")
_assert(
"installed = self._install_scdm_feature_cache" in finish_scdm_body
and "cache_ready=False" in finish_scdm_body,
"SCDM probe finish should not report success when cache installation is rejected",
)
deferred_edge_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_finish_deferred_edge_display")
_assert(
"_defer_scene_actor_update_if_interacting" in deferred_edge_body
and "_apply_deferred_edge_display_result" in deferred_edge_body,
"deferred edge actor installation should wait until camera interaction is idle",
)
scene_delay_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_scene_actor_update_should_wait_for_camera")
_assert(
"camera_interaction_active" in scene_delay_body
and "pointer_button_down" in scene_delay_body
and "last_camera_interaction_ended_at" in scene_delay_body,
"scene actor updates should be delayed during and immediately after camera interaction",
)
finish_edit_body = _function_text(PROJECT_ROOT / "step_editor/window_state.py", "_finish_scdm_edit_action")
_assert(
"_load_step_sync" in finish_edit_body
and "background=True" not in finish_edit_body,
"SCDM edit-result reload should avoid the background LoadWorker path that can hand worker-built VTK objects to the renderer",
)
load_sync_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_load_step_sync")
_assert(
"_reject_scdm_result_after_reload" in load_sync_body
and "result-load-failed" in load_sync_body,
"SCDM edit-result reload should catch scene-apply failures and roll back instead of leaking exceptions through Qt",
)
load_apply_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_apply_loaded_model_result")
_assert(
"pending_scdm_reload" in load_apply_body
and "if not pending_scdm_reload:\n self._clear_history()" in load_apply_body,
"SCDM edit-result reload should not run the ordinary import path that clears edit history while validation is pending",
)
def _assert_scdm_cache_revision_guard() -> None:
with tempfile.TemporaryDirectory(prefix="step_editor_cache_guard_") as temp:
step_path = Path(temp) / "guard.step"
step_path.write_text("ISO-10303-21;\nEND-ISO-10303-21;\n", encoding="utf-8")
fingerprint = file_fingerprint(step_path)
probe = _MouseSelectionProbe()
probe.step_path = step_path
probe.model = SimpleNamespace()
stale = {
"modelFingerprint": fingerprint,
"mapperRevision": max(0, SCDM_FEATURE_CACHE_REVISION - 1),
"objects": [{"objectId": "cylindrical_face_group:stale", "capabilities": [{"key": "hole.diameter"}]}],
}
installed = probe._install_scdm_feature_cache(stale)
_assert(installed is False, "stale SCDM mapper cache must not be installed")
_assert(probe.scdm_feature_cache is None and probe.scdm_feature_cache_state == "stale", "stale SCDM cache should clear UI cache state")
current = {
"modelFingerprint": fingerprint,
"mapperRevision": SCDM_FEATURE_CACHE_REVISION,
"objects": [{"objectId": "face:0", "capabilities": [{"key": "face.offset"}]}],
}
installed = probe._install_scdm_feature_cache(current)
_assert(installed is True, "current SCDM mapper cache should install")
_assert(probe._current_scdm_feature_cache_matches_loaded_step() is True, "current cache should match the loaded STEP")
def _assert_large_planar_offset_prefers_local_backend() -> None:
probe = _PropertyTableProbe()
probe.model = _LargeDisplayModel()
@@ -2043,6 +2489,8 @@ def main() -> int:
_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, f"software progress should not overpromise ambiguous slot edits: {progress_detail}")
_assert("不等于已经可改" in progress_detail, f"software progress should distinguish recognition from editability: {progress_detail}")
_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")
@@ -2080,11 +2528,17 @@ def main() -> int:
_assert_relation_radius_formula_proxy()
_assert_relation_formula_input_remains_editable_while_replaying()
_assert_relation_formula_input_clickable_after_existing_formula()
_assert_relation_formula_can_toggle_without_deleting()
_assert_relation_formula_import_export()
_assert_relation_formula_input_is_selection_independent()
_assert_relation_formula_input_recovers_after_loading()
_assert_relation_formula_ids_follow_model_remap()
_assert_mouse_selection_guards()
_assert_scdm_selection_diagnostics()
_assert_scdm_parameter_table_shows_only_enabled_specs()
_assert_ambiguous_slot_empty_state_explains_missing_params()
_assert_scdm_round_delete_conflict_filtered_for_blind_pocket()
_assert_scdm_first_holds_ambiguous_large_cylinders()
_assert_solid_selection_does_not_expand_face_scdm_specs()
_assert_operation_record_backend_sources()
_assert_scdm_auto_prompt()
@@ -2092,6 +2546,8 @@ def main() -> int:
_assert_worker_ui_callbacks_guarded()
_assert_large_model_preload_stays_lightweight()
_assert_large_model_selection_stays_lightweight()
_assert_vtk_interaction_stability_guards()
_assert_scdm_cache_revision_guard()
_assert_large_planar_offset_prefers_local_backend()
_assert_background_load_uses_worker()
_assert_quick_blind_depth_spec()
+25
View File
@@ -10,6 +10,8 @@ if str(PROJECT_ROOT) not in sys.path:
from step_editor.relation_formulas import ( # noqa: E402
RelationFormulaError,
Vector3,
evaluate_relation_formula,
parse_relation_formula,
validate_relation_formula_graph,
)
@@ -34,10 +36,33 @@ def _assert_fails(texts: list[str], expected_fragment: str) -> None:
raise AssertionError(f"expected relation formulas to fail: {texts!r}")
def _assert_value(text: str, expected: object, values: dict[str, object] | None = None) -> None:
formula = parse_relation_formula(text)
values = dict(values or {})
value = evaluate_relation_formula(formula, lambda ref: values[ref.token])
if isinstance(value, Vector3):
actual = tuple(value.values)
expected_tuple = tuple(expected) # type: ignore[arg-type]
if len(actual) != len(expected_tuple) or any(abs(float(left) - float(right)) > 1.0e-12 for left, right in zip(actual, expected_tuple)):
raise AssertionError(f"expected {expected_tuple!r}, got {actual!r} for {text!r}")
return
if abs(float(value) - float(expected)) > 1.0e-12:
raise AssertionError(f"expected {expected!r}, got {value!r} for {text!r}")
def main() -> int:
_assert_ok(["Face87.直径 = Face87.半径 + 0.1"])
_assert_ok(["Face87.位置 = Face85.位置 + (0, 0, -3.5)"])
_assert_ok(["Face85.直径 = Face87.半径", "Face11.直径 = Face85.半径"])
_assert_ok(["Face87.直径 = 10mm"])
_assert_ok(["Face87.直径 = 1 cm + 2mm"])
_assert_value("Face87.直径 = 1cm + 2毫米", 12.0)
_assert_value("Face87.直径 = .5m / 10", 50.0)
_assert_value(
"Face87.位置 = Face85.位置 + (0mm, 1cm, -0.002m)",
(1.0, 12.0, 1.0),
{"Face85.位置": (1.0, 2.0, 3.0)},
)
_assert_fails(["Face87.直径 = Face87.直径 + 0.1"], "不能引用自身")
_assert_fails(["Face85.直径 = Face87.半径", "Face85.直径 = Face11.半径"], "同一目标参数")
+238 -13
View File
@@ -175,10 +175,15 @@ def main() -> int:
"change_boss_diameter",
"change_round_radius",
"change_chamfer_distance",
"change_shell_thickness",
"change_pattern_spacing",
"change_pattern_segment_spacing",
"move_pattern_instance",
"segment_before",
"segment_split",
"segment_single_left",
"segment_single_right",
"wallFaceLocators",
"pull_face_offset",
"fill_feature",
"delete_round_or_chamfer",
@@ -207,6 +212,11 @@ def main() -> int:
):
_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")
_assert("_delete_selection(selection, fill_errors)" in script, "generated edit script should fall back from Fill to Delete")
_assert(
"if fill is None:\n raise Exception('capability_not_implemented: Fill command not available')" not in script,
"Delete-capable SCDM environments must not fail merely because Fill is missing",
)
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")
@@ -253,6 +263,19 @@ def main() -> int:
_assert(slot_width_job.get("target", {}).get("backendOperation") == "change_slot_width", f"slot.width should route to change_slot_width: {slot_width_job}")
_assert(slot_width_job.get("target", {}).get("value") == 2.5, f"slot.width target should be numeric: {slot_width_job}")
_assert(slot_width_job.get("object", {}).get("geometrySignature", {}).get("width") == 2.0, f"slot width signature should be preserved: {slot_width_job}")
slot_width_noop = prepare_scdm_edit_job(
step_path,
output_dir=root / "slot-width-noop",
backend=backend,
capability_key="slot.width",
target_value="2.0",
object_id="slot:30-31-32",
object_signature=slot_signature,
)
_assert(
slot_width_noop.get("ok") is False and slot_width_noop.get("reason") == "target-already-current",
f"slot.width no-op should be blocked before launching SCDM: {slot_width_noop}",
)
slot_depth_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "slot-depth-prepared",
@@ -270,6 +293,22 @@ def main() -> int:
_assert(slot_depth_signature.get("depth") == 1.5, f"slot depth signature should be preserved: {slot_depth_job}")
_assert(slot_depth_signature.get("depthFaceLocators"), f"slot depth bottom face locator should be preserved: {slot_depth_job}")
_assert(slot_depth_signature.get("depthAxis") == [0.0, 0.0, -1.0], f"slot depth axis should be preserved: {slot_depth_job}")
slot_depth_missing_locator_signature = dict(slot_signature)
slot_depth_missing_locator_signature.pop("depthFaceLocators", None)
slot_depth_missing_locator = prepare_scdm_edit_job(
step_path,
output_dir=root / "slot-depth-missing-locator",
backend=backend,
capability_key="slot.depth",
target_value="2.0",
object_id="slot:30-31-32",
object_signature=slot_depth_missing_locator_signature,
)
_assert(
slot_depth_missing_locator.get("ok") is False
and slot_depth_missing_locator.get("reason") == "object-signature-missing-depth-face-locator",
f"slot.depth should be blocked before SCDM when bottom face locator is missing: {slot_depth_missing_locator}",
)
boss_signature = {
"objectType": "cylindrical_boss",
@@ -316,6 +355,22 @@ def main() -> int:
boss_height_signature = boss_height_job.get("object", {}).get("geometrySignature", {})
_assert(boss_height_signature.get("height") == 4.0, f"boss height signature should be preserved: {boss_height_job}")
_assert(boss_height_signature.get("heightFaceLocators"), f"boss height top face locator should be preserved: {boss_height_job}")
boss_height_missing_locator_signature = dict(boss_signature)
boss_height_missing_locator_signature.pop("heightFaceLocators", None)
boss_height_missing_locator = prepare_scdm_edit_job(
step_path,
output_dir=root / "boss-height-missing-locator",
backend=backend,
capability_key="boss.height",
target_value="5.5",
object_id="boss:50-51-52",
object_signature=boss_height_missing_locator_signature,
)
_assert(
boss_height_missing_locator.get("ok") is False
and boss_height_missing_locator.get("reason") == "object-signature-missing-height-face-locator",
f"boss.height should be blocked before SCDM when top face locator is missing: {boss_height_missing_locator}",
)
boss_diameter_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "boss-diameter-prepared",
@@ -332,6 +387,48 @@ def main() -> int:
boss_diameter_signature = boss_diameter_job.get("object", {}).get("geometrySignature", {})
_assert(boss_diameter_signature.get("diameter") == 3.0, f"boss diameter signature should be preserved: {boss_diameter_job}")
_assert(boss_diameter_signature.get("diameterFaceLocators"), f"boss diameter side face locator should be preserved: {boss_diameter_job}")
boss_diameter_missing_locator_signature = dict(boss_signature)
boss_diameter_missing_locator_signature.pop("diameterFaceLocators", None)
boss_diameter_missing_locator = prepare_scdm_edit_job(
step_path,
output_dir=root / "boss-diameter-missing-locator",
backend=backend,
capability_key="boss.diameter",
target_value="4.5",
object_id="boss:50-51-52",
object_signature=boss_diameter_missing_locator_signature,
)
_assert(
boss_diameter_missing_locator.get("ok") is False
and boss_diameter_missing_locator.get("reason") == "object-signature-missing-diameter-face-locator",
f"boss.diameter should be blocked before SCDM when side face locator is missing: {boss_diameter_missing_locator}",
)
hole_position_noop = prepare_scdm_edit_job(
step_path,
output_dir=root / "hole-position-noop",
backend=backend,
capability_key="hole.position",
target_value=[0.5, 1.0, 9.5],
object_id="hole:85-94",
object_signature=signature,
)
_assert(
hole_position_noop.get("ok") is False and hole_position_noop.get("reason") == "target-already-current",
f"hole.position no-op should be blocked before launching SCDM: {hole_position_noop}",
)
boss_height_zero = prepare_scdm_edit_job(
step_path,
output_dir=root / "boss-height-zero",
backend=backend,
capability_key="boss.height",
target_value="0",
object_id="boss:50-51-52",
object_signature=boss_signature,
)
_assert(
boss_height_zero.get("ok") is False and boss_height_zero.get("reason") == "target-value-illegal",
f"positive dimensions should reject zero before launching SCDM: {boss_height_zero}",
)
round_signature = {
"objectType": "round",
@@ -404,6 +501,55 @@ def main() -> int:
_assert(chamfer_job_signature.get("isEqualDistanceChamfer") is True, f"chamfer.distance should preserve equal-distance evidence: {chamfer_distance_job}")
_assert(chamfer_job_signature.get("distance") == 0.8, f"chamfer.distance should preserve current distance: {chamfer_distance_job}")
shell_signature = {
"objectType": "thin_wall",
"faceIds": [101, 102],
"bodyIndex": 0,
"faceOrdinals": [101, 102],
"globalFaceOrdinals": [201, 202],
"thickness": 1.2,
"thicknessAxis": [0.0, 0.0, 1.0],
"wallFaceCenters": [[0.0, 0.0, 0.0], [0.0, 0.0, 1.2]],
"wallFaceLocators": [
{"bodyIndex": 0, "faceOrdinal": 101, "globalFaceOrdinal": 201},
{"bodyIndex": 0, "faceOrdinal": 102, "globalFaceOrdinal": 202},
],
}
shell_thickness_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "shell-thickness-prepared",
backend=backend,
capability_key="shell.thickness",
target_value="1.6",
object_id="thin_wall:101-102",
object_signature=shell_signature,
)
_assert(shell_thickness_prepared.get("ok") is True, f"shell.thickness should be productized and prepare an edit job: {shell_thickness_prepared}")
shell_thickness_job = read_json(shell_thickness_prepared["job_path"])
_assert(shell_thickness_job.get("target", {}).get("backendOperation") == "change_shell_thickness", f"shell.thickness should route to change_shell_thickness: {shell_thickness_job}")
_assert(shell_thickness_job.get("target", {}).get("value") == 1.6, f"shell.thickness target should be numeric: {shell_thickness_job}")
shell_job_signature = shell_thickness_job.get("object", {}).get("geometrySignature", {})
_assert(shell_job_signature.get("thickness") == 1.2, f"shell thickness signature should be preserved: {shell_thickness_job}")
_assert(shell_job_signature.get("wallFaceLocators"), f"shell wall face locators should be preserved: {shell_thickness_job}")
shell_missing_locator_signature = dict(shell_signature)
shell_missing_locator_signature.pop("wallFaceLocators", None)
shell_missing_locator_signature.pop("faceOrdinals", None)
shell_missing_locator_signature.pop("globalFaceOrdinals", None)
shell_missing_locator = prepare_scdm_edit_job(
step_path,
output_dir=root / "shell-thickness-missing-locator",
backend=backend,
capability_key="shell.thickness",
target_value="1.6",
object_id="thin_wall:101-102",
object_signature=shell_missing_locator_signature,
)
_assert(
shell_missing_locator.get("ok") is False
and shell_missing_locator.get("reason") == "object-signature-missing-wall-face-locator",
f"shell.thickness should be blocked before SCDM when both wall locators are missing: {shell_missing_locator}",
)
pattern_signature = {
"objectType": "linear_pattern",
"faceIds": [85, 94, 87, 96, 89, 98],
@@ -514,6 +660,73 @@ def main() -> int:
and read_json(pattern_segment_split_prepared["job_path"]).get("object", {}).get("geometrySignature", {}).get("movingSide") == "split",
f"pattern.segment_spacing should preserve split/keep-center semantics: {pattern_segment_split_prepared}",
)
pattern_segment_single_left_signature = dict(pattern_segment_signature)
pattern_segment_single_left_signature["movingSide"] = "single_left"
pattern_segment_single_left_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "pattern-segment-spacing-single-left-prepared",
backend=backend,
capability_key="pattern.segment_spacing",
target_value="6.5",
object_id="pattern:holes-a-b-c",
object_signature=pattern_segment_single_left_signature,
)
_assert(
pattern_segment_single_left_prepared.get("ok") is True
and read_json(pattern_segment_single_left_prepared["job_path"]).get("object", {}).get("geometrySignature", {}).get("movingSide") == "single_left",
f"pattern.segment_spacing should preserve move-only-left-instance semantics: {pattern_segment_single_left_prepared}",
)
pattern_segment_single_right_signature = dict(pattern_segment_signature)
pattern_segment_single_right_signature["movingSide"] = "single_right"
pattern_segment_single_right_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "pattern-segment-spacing-single-right-prepared",
backend=backend,
capability_key="pattern.segment_spacing",
target_value="6.5",
object_id="pattern:holes-a-b-c",
object_signature=pattern_segment_single_right_signature,
)
_assert(
pattern_segment_single_right_prepared.get("ok") is True
and read_json(pattern_segment_single_right_prepared["job_path"]).get("object", {}).get("geometrySignature", {}).get("movingSide") == "single_right",
f"pattern.segment_spacing should preserve move-only-right-instance semantics: {pattern_segment_single_right_prepared}",
)
pattern_instance_signature = dict(pattern_signature["patternInstances"][1])
pattern_instance_prepared = prepare_scdm_edit_job(
step_path,
output_dir=root / "pattern-instance-position-prepared",
backend=backend,
capability_key="pattern.instance_position",
target_value=[6.0, 0.0, 0.0],
object_id="pattern:holes-a-b-c:instance-b",
object_signature=pattern_instance_signature,
)
_assert(pattern_instance_prepared.get("ok") is True, f"pattern.instance_position should prepare an edit job when instance locators exist: {pattern_instance_prepared}")
pattern_instance_job = read_json(pattern_instance_prepared["job_path"])
_assert(pattern_instance_job.get("target", {}).get("backendOperation") == "move_pattern_instance", f"pattern.instance_position should route to move_pattern_instance: {pattern_instance_job}")
_assert(pattern_instance_job.get("target", {}).get("value") == [6.0, 0.0, 0.0], f"pattern.instance_position target should be vector3: {pattern_instance_job}")
pattern_instance_missing_locator = dict(pattern_instance_signature)
pattern_instance_missing_locator.pop("scdmFaceLocators", None)
pattern_instance_missing_locator.pop("faceOrdinals", None)
pattern_instance_missing_locator.pop("globalFaceOrdinals", None)
pattern_instance_missing_locator.pop("bodyLocators", None)
pattern_instance_missing_locator.pop("componentLocators", None)
pattern_instance_missing_locator.pop("bodyIndex", None)
blocked_pattern_instance = prepare_scdm_edit_job(
step_path,
output_dir=root / "pattern-instance-position-missing-locator",
backend=backend,
capability_key="pattern.instance_position",
target_value=[6.0, 0.0, 0.0],
object_id="pattern:holes-a-b-c:instance-b",
object_signature=pattern_instance_missing_locator,
)
_assert(
blocked_pattern_instance.get("ok") is False
and blocked_pattern_instance.get("reason") == "object-signature-missing-pattern-instance-locator",
f"pattern.instance_position should be blocked before SCDM when instance locator is missing: {blocked_pattern_instance}",
)
body_pattern_signature = {
"objectType": "linear_pattern",
@@ -658,19 +871,6 @@ def main() -> int:
f"pattern segment spacing should be blocked before SCDM when it exceeds support Face limits: {blocked_segment_prepared}",
)
planned = prepare_scdm_edit_job(
step_path,
output_dir=root / "unsupported",
backend=backend,
capability_key="pattern.instance_position",
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",
@@ -823,6 +1023,19 @@ def main() -> int:
_assert(chamfer_distance_success.get("ok") is True, f"fake chamfer.distance edit should succeed: {chamfer_distance_success}")
_assert(chamfer_distance_success.get("backend_operation") == "change_chamfer_distance", f"chamfer.distance should report change_chamfer_distance: {chamfer_distance_success}")
shell_thickness_success = run_scdm_edit_job(
step_path,
output_dir=root / "shell-thickness-success",
backend=backend,
capability_key="shell.thickness",
target_value=1.6,
object_id="thin_wall:101-102",
object_signature=shell_signature,
runner=_successful_runner,
)
_assert(shell_thickness_success.get("ok") is True, f"fake shell.thickness edit should succeed: {shell_thickness_success}")
_assert(shell_thickness_success.get("backend_operation") == "change_shell_thickness", f"shell.thickness should report change_shell_thickness: {shell_thickness_success}")
pattern_spacing_success = run_scdm_edit_job(
step_path,
output_dir=root / "pattern-spacing-success",
@@ -850,6 +1063,18 @@ def main() -> int:
pattern_segment_success.get("backend_operation") == "change_pattern_segment_spacing",
f"pattern.segment_spacing should report change_pattern_segment_spacing: {pattern_segment_success}",
)
pattern_instance_success = run_scdm_edit_job(
step_path,
output_dir=root / "pattern-instance-position-success",
backend=backend,
capability_key="pattern.instance_position",
target_value=[6.0, 0.0, 0.0],
object_id="pattern:holes-a-b-c:instance-b",
object_signature=pattern_instance_signature,
runner=_successful_runner,
)
_assert(pattern_instance_success.get("ok") is True, f"fake pattern.instance_position edit should succeed: {pattern_instance_success}")
_assert(pattern_instance_success.get("backend_operation") == "move_pattern_instance", f"pattern.instance_position should report move_pattern_instance: {pattern_instance_success}")
missing_output = run_scdm_edit_job(
step_path,
+181 -13
View File
@@ -326,6 +326,42 @@ def main() -> int:
],
"rawLimitations": [],
},
{
"backendId": "body:0/face:1722",
"objectType": "round",
"geometry": {
"surfaceType": "cylinder",
"radius": 0.0013,
"diameter": 0.0026,
"center": [-0.083, -0.031, -0.11635],
"axis": [0.0, 1.0, 0.0],
"roundInfo": {"radius": 0.0, "available": True, "type": "RoundInfoResult"},
},
"topologyHint": {"faceIds": [1722], "bodyIndex": 0, "faceOrdinal": 1722, "globalFaceOrdinal": 1722},
"backendCommandCandidates": [
{"operation": "change_round_radius", "enabled": True},
{"operation": "delete_round_or_chamfer", "enabled": True},
],
"rawLimitations": [],
},
{
"backendId": "body:0/face:1723",
"objectType": "round",
"geometry": {
"surfaceType": "cylinder",
"radius": 0.0013,
"diameter": 0.0026,
"center": [-0.083, -0.031, -0.11635],
"axis": [0.0, 1.0, 0.0],
"roundInfo": {"radius": 0.0, "available": True, "type": "RoundInfoResult"},
},
"topologyHint": {"faceIds": [1723], "bodyIndex": 0, "faceOrdinal": 1723, "globalFaceOrdinal": 1723},
"backendCommandCandidates": [
{"operation": "change_round_radius", "enabled": True},
{"operation": "delete_round_or_chamfer", "enabled": True},
],
"rawLimitations": [],
},
{
"backendId": "slot:1",
"objectType": "slot",
@@ -549,6 +585,36 @@ def main() -> int:
_assert(isinstance(round_object, dict), f"round object should be normalized: {cache}")
round_signature = round_object.get("geometrySignature")
_assert(isinstance(round_signature, dict) and round_signature.get("isConstantRound") is True, f"round constant evidence should be preserved: {round_signature}")
round_split_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(1722,), execution_ready=True)
round_split_keys = {str(spec.get("scdm_capability_key")) for spec in round_split_specs}
round_split_group = next(
(
item
for item in objects
if isinstance(item, dict)
and item.get("objectType") == "cylindrical_face_group"
and {1722, 1723} <= set(item.get("geometrySignature", {}).get("faceIds", []))
),
None,
)
_assert(
round_split_group is None,
f"SCDM round cylinder fragments must not be derived into a cylindrical hole group: {round_split_group}",
)
round_split_radius_spec = next((spec for spec in round_split_specs if spec.get("scdm_capability_key") == "round.radius"), None)
round_split_radius_value = float(str(round_split_radius_spec.get("current_raw"))) if round_split_radius_spec else None
_assert(
round_split_radius_value is not None and abs(round_split_radius_value - 0.0013) < 1.0e-9,
f"SCDM roundInfo radius=0 should fall back to geometric radius: {round_split_specs}",
)
_assert(
"hole.diameter" not in round_split_keys and "hole.position" not in round_split_keys,
f"SCDM round cylinder faces must not be derived into editable hole groups: {round_split_specs}",
)
_assert(
"feature.delete_round_or_chamfer" in round_split_keys,
f"SCDM round cylinder faces should keep their own round/delete capabilities: {round_split_specs}",
)
round_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(60,), execution_ready={"round.radius", "feature.delete_round_or_chamfer"})
_assert(
{str(spec.get("scdm_capability_key")) for spec in round_specs} == {"round.radius", "feature.delete_round_or_chamfer"},
@@ -812,6 +878,56 @@ def main() -> int:
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}")
delete_only_raw = {
"schemaVersion": 1,
"backend": {"name": "SCDM", "version": "v222"},
"model": {"path": str(step_path), "fingerprint": "abc123", "unit": "mm"},
"diagnostics": {"availableCommands": [{"name": "Delete", "available": True}, {"name": "Fill", "available": False}]},
"objects": [
{
"backendId": "round:delete-only",
"objectType": "round",
"geometry": {"roundInfo": {"radius": 1.0, "isConstant": True, "isRound": True}},
"topologyHint": {"faceIds": [48], "bodyIndex": 0, "faceOrdinal": 48, "globalFaceOrdinal": 48},
"backendCommandCandidates": [{"operation": "delete_round_or_chamfer", "enabled": True}],
"rawLimitations": [],
}
],
}
delete_only_specs = property_specs_from_scdm_cache(
map_scdm_raw_features(delete_only_raw),
selected_face_ids=(48,),
execution_ready={"feature.delete_round_or_chamfer"},
)
delete_only_spec = next((spec for spec in delete_only_specs if spec.get("scdm_capability_key") == "feature.delete_round_or_chamfer"), None)
_assert(delete_only_spec and delete_only_spec.get("enabled") is True, f"Delete-only SCDM should enable round/chamfer removal: {delete_only_specs}")
shell_without_locator_raw = {
"schemaVersion": 1,
"backend": {"name": "SCDM", "version": "v222"},
"model": {"path": str(step_path), "fingerprint": "abc123", "unit": "mm"},
"diagnostics": {"availableCommands": [{"name": "Move", "available": True}]},
"objects": [
{
"backendId": "shell:local-face-ids-only",
"objectType": "shell",
"geometry": {"thickness": 1.2, "thicknessAxis": [0.0, 0.0, 1.0]},
"topologyHint": {"faceIds": [149, 150]},
"backendCommandCandidates": [{"operation": "change_shell_thickness", "enabled": True}],
"rawLimitations": [],
}
],
}
shell_without_locator_specs = property_specs_from_scdm_cache(
map_scdm_raw_features(shell_without_locator_raw),
selected_face_ids=(149,),
execution_ready={"shell.thickness"},
)
shell_without_locator_spec = next((spec for spec in shell_without_locator_specs if spec.get("scdm_capability_key") == "shell.thickness"), None)
_assert(
shell_without_locator_spec and shell_without_locator_spec.get("enabled") is False,
f"shell.thickness should not execute when only local Face IDs are known: {shell_without_locator_specs}",
)
_assert("两侧墙面" in str(shell_without_locator_spec.get("disabled_tip") or ""), f"shell.thickness disabled reason should explain missing wall locators: {shell_without_locator_specs}")
missing_current_raw = {
"schemaVersion": 1,
"backend": {"name": "SCDM", "version": "v222"},
@@ -1052,10 +1168,10 @@ def main() -> int:
f"SCDM component occurrence diagnostics 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}")
_assert(isinstance(geometry_hints, list), f"SCDM structural candidate hints should be preserved as a list: {cache}")
hint_keys = {str(item.get("capabilityKey")) for item in geometry_hints if isinstance(item, dict)}
_assert(
{"pattern.instance_position", "shell.thickness"} <= hint_keys and "slot.depth" not in hint_keys and "pattern.spacing" not in hint_keys,
"pattern.instance_position" not in hint_keys and "shell.thickness" not in hint_keys and "slot.depth" not in hint_keys and "pattern.spacing" not in hint_keys,
f"S7 geometry hints should cover planned feature families: {hint_keys}",
)
derived_candidates = diagnostics.get("derived_feature_candidates")
@@ -1077,6 +1193,11 @@ def main() -> int:
_assert(thin_wall_signature.get("thickness") == 1.2, f"thin-wall thickness should be preserved: {thin_wall_signature}")
_assert(thin_wall_signature.get("thicknessAxis") == [0.0, 0.0, 1.0], f"thin-wall axis should be preserved: {thin_wall_signature}")
_assert(len(thin_wall_signature.get("wallFaceLocators") or []) == 2, f"thin-wall face locators should be preserved: {thin_wall_signature}")
_assert({"shell.thickness"} <= _capability_keys(cache, "derived:thin_wall"), f"derived thin wall should expose productized shell thickness: {cache}")
thin_wall_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(101,), execution_ready={"shell.thickness"})
thin_wall_thickness_spec = next((spec for spec in thin_wall_specs if spec.get("scdm_capability_key") == "shell.thickness"), None)
_assert(thin_wall_thickness_spec and thin_wall_thickness_spec.get("enabled") is True, f"thin-wall thickness should enable when both wall faces are locatable: {thin_wall_specs}")
_assert(thin_wall_thickness_spec.get("scope_text") == "固定一侧,移动另一侧", f"thin-wall thickness intent should be explicit: {thin_wall_specs}")
body_pattern_candidate = next(
(
item
@@ -1097,7 +1218,7 @@ def main() -> int:
body_instances = body_pattern_signature.get("patternInstances") or []
_assert(len(body_instances) == 3 and all(item.get("instanceKind") == "body" for item in body_instances), f"body pattern instances should stay body-level: {body_pattern_signature}")
_assert(all(item.get("componentLocators") for item in body_instances if isinstance(item, dict)), f"body pattern instances should preserve component occurrence locators: {body_pattern_signature}")
body_pattern_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(301,), execution_ready={"pattern.spacing", "pattern.segment_spacing"})
body_pattern_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(301,), execution_ready={"pattern.spacing", "pattern.segment_spacing", "pattern.instance_position"})
body_pattern_spacing = [
spec for spec in body_pattern_specs if spec.get("scdm_capability_key") == "pattern.spacing"
]
@@ -1111,7 +1232,7 @@ def main() -> int:
]
_assert(len(body_segment_specs) == 2, f"three body pattern members should expose two local spacing segments: {body_pattern_specs}")
_assert(
"零件" in str(body_segment_specs[0].get("label") or "")
"Solid" in str(body_segment_specs[0].get("label") or "")
and body_segment_specs[0].get("enabled") is True
and body_segment_specs[0].get("scdm_backend_operation") == "change_pattern_segment_spacing",
f"local body pattern spacing should be executable and name the adjacent members: {body_segment_specs}",
@@ -1119,15 +1240,16 @@ def main() -> int:
body_segment_modes = body_segment_specs[0].get("scope_modes")
_assert(
isinstance(body_segment_modes, dict)
and {"fix_left_move_right", "fix_right_move_left", "split_keep_center", "move_single_right"} <= set(body_segment_modes),
and {"fix_left_move_right", "fix_right_move_left", "split_keep_center", "move_single_left", "move_single_right"} <= set(body_segment_modes),
f"local pattern spacing should expose several explicit modeling intents: {body_segment_specs}",
)
_assert(
body_segment_modes["fix_left_move_right"].get("enabled") is True
and body_segment_modes["fix_right_move_left"].get("enabled") is True
and body_segment_modes["split_keep_center"].get("enabled") is True
and body_segment_modes["move_single_right"].get("enabled") is False,
f"implemented and blocked local spacing intents should be explicit: {body_segment_modes}",
and body_segment_modes["move_single_left"].get("enabled") is True
and body_segment_modes["move_single_right"].get("enabled") is True,
f"implemented local spacing intents should be explicit: {body_segment_modes}",
)
for mode in body_segment_modes.values():
if not isinstance(mode, dict):
@@ -1135,6 +1257,11 @@ def main() -> int:
label = str(mode.get("label") or "").strip()
tip = str(mode.get("enabled_tip") or mode.get("disabled_tip") or mode.get("range_hint") or "").strip()
_assert(label and tip.startswith(label), f"modeling-intent tooltip should start with its intent label: {body_segment_modes}")
_assert(
"不用于保持整列等距" in str(body_segment_modes["move_single_right"].get("range_hint") or "")
and "不用于保持整列等距" in str(body_segment_modes["move_single_left"].get("range_hint") or ""),
f"single-instance local spacing intents should warn that they break equal spacing: {body_segment_modes}",
)
_assert(
"固定前项" in str(body_segment_specs[0].get("scope_text") or "")
and "移动后侧" in str(body_segment_specs[0].get("scope_text") or ""),
@@ -1147,7 +1274,44 @@ def main() -> int:
and body_segment_signature.get("movingSide") == "after",
f"local segment specs should carry segment execution metadata: {body_segment_specs}",
)
pattern_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(201,), execution_ready={"pattern.spacing", "pattern.segment_spacing"})
body_instance_position = next((spec for spec in body_pattern_specs if spec.get("scdm_capability_key") == "pattern.instance_position"), None)
_assert(
body_instance_position
and body_instance_position.get("enabled") is True
and body_instance_position.get("value_type") == "vector3"
and body_instance_position.get("scdm_backend_operation") == "move_pattern_instance",
f"body pattern member should expose a single-instance position edit: {body_pattern_specs}",
)
_assert(
"只移动该实例" in str(body_instance_position.get("scope_text") or "")
and "不自动保持整体阵列等距" in str(body_instance_position.get("range_hint") or ""),
f"pattern instance position should explain that it is not a spacing edit: {body_instance_position}",
)
solid_body_pattern_specs = property_specs_from_scdm_cache(
cache,
selected_solid_ids=(21,),
execution_ready={"pattern.spacing", "pattern.segment_spacing", "pattern.instance_position", "face.offset"},
)
solid_pattern_keys = [str(spec.get("scdm_capability_key") or "") for spec in solid_body_pattern_specs]
_assert(
"pattern.spacing" in solid_pattern_keys
and "pattern.segment_spacing" in solid_pattern_keys
and "pattern.instance_position" in solid_pattern_keys,
f"selecting a Solid member should expose body-level pattern capabilities: {solid_body_pattern_specs}",
)
_assert(
"face.offset" not in solid_pattern_keys,
f"selecting a Solid should not expand child Face offsets from the SCDM cache: {solid_body_pattern_specs}",
)
solid_instance_specs = [
spec for spec in solid_body_pattern_specs if spec.get("scdm_capability_key") == "pattern.instance_position"
]
_assert(
len(solid_instance_specs) == 1
and str(solid_instance_specs[0].get("label") or "").startswith("Solid21"),
f"Solid selection should expose only the selected member position row: {solid_body_pattern_specs}",
)
pattern_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(201,), execution_ready={"pattern.spacing", "pattern.segment_spacing", "pattern.instance_position"})
pattern_spacing_spec = next((spec for spec in pattern_specs if spec.get("scdm_capability_key") == "pattern.spacing"), None)
_assert(pattern_spacing_spec and pattern_spacing_spec.get("enabled") is True, f"pattern.spacing should enable for selected pattern member: {pattern_specs}")
_assert(pattern_spacing_spec.get("value_type") == "positive", f"pattern.spacing should use positive numeric input: {pattern_specs}")
@@ -1159,16 +1323,20 @@ def main() -> int:
str(face_segment_specs[0].get("label") or "").startswith("Face"),
f"face pattern local spacing should name the adjacent Face members instead of raw ordinal numbers: {face_segment_specs}",
)
face_instance_position = next((spec for spec in pattern_specs if spec.get("scdm_capability_key") == "pattern.instance_position"), None)
_assert(
face_instance_position
and face_instance_position.get("enabled") is True
and str(face_instance_position.get("label") or "").startswith("Face"),
f"face pattern member should expose its own position edit with visible Face ID: {pattern_specs}",
)
not_productized = diagnostics.get("discovered_not_productized")
_assert(isinstance(not_productized, list) and not_productized, f"non-productized candidates 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 = {
"pattern.instance_position",
"shell.thickness",
}
_assert(expected_planned <= planned_keys, f"S7 planned capabilities should be diagnosed but not productized: {planned_keys}")
_assert("pattern.instance_position" not in planned_keys, f"pattern.instance_position should now be productized with member-level specs: {planned_keys}")
_assert("shell.thickness" not in planned_keys, f"shell.thickness should now be productized with strict geometry gates: {planned_keys}")
raw_file = root / "raw.json"
cache_file = root / "cache.json"
+99
View File
@@ -543,6 +543,80 @@ def main() -> int:
pattern_segment_mismatch.get("ok") is False and pattern_segment_mismatch.get("reason") == "target-mismatch",
f"pattern.segment_spacing mismatch should fail from the applied edit result: {pattern_segment_mismatch}",
)
single_left_signature = dict(before_pattern_segment["objects"][0]["geometrySignature"]) # type: ignore[index]
single_left_signature["movingSide"] = "single_left"
single_left_ok = validate_scdm_edit_result(
{
"ok": True,
"output_step": str(output_step),
"applied": {"segmentSpacing": 6.5, "segmentIndex": 1, "spacingMode": "segment_single_left"},
},
before_signature=single_left_signature,
before_cache=before_pattern_segment,
after_cache=after_pattern_segment,
capability_key="pattern.segment_spacing",
expected_target=6.5,
edited_object_id="pattern:holes",
)
_assert(
single_left_ok.get("ok") is True
and single_left_ok.get("targetCheck", {}).get("spacingMode") == "segment_single_left",
f"pattern.segment_spacing should validate move-only-left-instance mode: {single_left_ok}",
)
single_right_signature = dict(before_pattern_segment["objects"][0]["geometrySignature"]) # type: ignore[index]
single_right_signature["movingSide"] = "single_right"
single_right_ok = validate_scdm_edit_result(
{
"ok": True,
"output_step": str(output_step),
"applied": {"segmentSpacing": 6.5, "segmentIndex": 1, "spacingMode": "segment_single_right"},
},
before_signature=single_right_signature,
before_cache=before_pattern_segment,
after_cache=after_pattern_segment,
capability_key="pattern.segment_spacing",
expected_target=6.5,
edited_object_id="pattern:holes",
)
_assert(
single_right_ok.get("ok") is True
and single_right_ok.get("targetCheck", {}).get("spacingMode") == "segment_single_right",
f"pattern.segment_spacing should validate move-only-right-instance mode: {single_right_ok}",
)
wrong_mode = validate_scdm_edit_result(
{
"ok": True,
"output_step": str(output_step),
"applied": {"segmentSpacing": 6.5, "segmentIndex": 1, "spacingMode": "segment_after"},
},
before_signature=single_right_signature,
before_cache=before_pattern_segment,
after_cache=after_pattern_segment,
capability_key="pattern.segment_spacing",
expected_target=6.5,
edited_object_id="pattern:holes",
)
_assert(
wrong_mode.get("ok") is False and wrong_mode.get("reason") == "pattern-segment-spacing-mode-mismatch",
f"pattern.segment_spacing should fail when SCDM reports a different modeling intent: {wrong_mode}",
)
missing_mode = validate_scdm_edit_result(
{
"ok": True,
"output_step": str(output_step),
"applied": {"segmentSpacing": 6.5, "segmentIndex": 1},
},
before_signature=single_right_signature,
before_cache=before_pattern_segment,
after_cache=after_pattern_segment,
capability_key="pattern.segment_spacing",
expected_target=6.5,
edited_object_id="pattern:holes",
)
_assert(
missing_mode.get("ok") is False and missing_mode.get("reason") == "pattern-segment-spacing-mode-missing",
f"pattern.segment_spacing should require SCDM to report a known modeling intent: {missing_mode}",
)
shell_thickness_ok = check_scdm_target(
{
"objectType": "thin_wall",
@@ -574,6 +648,31 @@ def main() -> int:
)
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_repartition = _cache_with_summary(
{"bodyCount": 9, "objectCount": 548, "faceCount": 157, "edgeCount": 390},
_hole("hole:90", 90, diameter=0.9, center=(0.5, 1.0, 9.5)),
)
summary_repartition = check_scdm_summary_delta(summary_before, summary_after_repartition, capability_key="hole.diameter")
_assert(
summary_repartition.get("ok") is None and summary_repartition.get("reason") == "body-count-repartitioned",
f"SCDM body repartition should be a warning, not a hard failure: {summary_repartition}",
)
repartition_ok = 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_repartition,
capability_key="hole.diameter",
expected_target=0.9,
edited_object_id="hole:85",
brep_validator=lambda path: {"ok": path.is_file(), "reason": "ok"},
)
_assert(
repartition_ok.get("ok") is True
and repartition_ok.get("summaryCheck", {}).get("reason") == "body-count-repartitioned"
and repartition_ok.get("validationWarnings"),
f"target-verified SCDM hole diameter edit should survive body repartition warnings: {repartition_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)),
+9 -8
View File
@@ -113,9 +113,7 @@ def main() -> int:
"geometrySignature": {"spacing": 5.0, "instanceCount": 3},
}
],
"planned_not_productized": [
{"capabilityKey": "shell.thickness", "objectType": "shell"},
],
"planned_not_productized": [],
"discovered_not_productized": [
{"objectType": "mystery_feature"},
{"objectType": "mystery_feature"},
@@ -140,14 +138,16 @@ def main() -> int:
"feature.delete_round_or_chamfer",
"pattern.spacing",
"pattern.segment_spacing",
"pattern.instance_position",
"shell.thickness",
},
)
summary = progress.get("summary")
_assert(isinstance(summary, dict), f"capability progress should include summary: {progress}")
_assert(summary.get("productized") == 15, f"productized capability count should include S5 plus slot dimensions, boss dimensions, round/chamfer dimensions, pattern spacing, local segment spacing, Move-based S7 entries and round/chamfer delete: {summary}")
_assert(summary.get("runnerReady") == 15, f"runner-ready capability count should honor UI gate: {summary}")
_assert(summary.get("productized") == 17, f"productized capability count should include S5 plus slot dimensions, boss dimensions, round/chamfer dimensions, pattern spacing, local segment spacing, instance position, shell thickness, Move-based S7 entries and round/chamfer delete: {summary}")
_assert(summary.get("runnerReady") == 17, f"runner-ready capability count should honor UI gate: {summary}")
_assert(summary.get("executableCapabilities") == 3, f"blocked/ungated capabilities should not be executable: {summary}")
_assert(summary.get("plannedDetected") == 1, f"planned S7 detections should be counted: {summary}")
_assert(summary.get("plannedDetected") == 0, 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(
@@ -169,8 +169,9 @@ def main() -> int:
_assert("倒角距离:已开放待识别" in productized_lines, f"productized chamfer.distance should be visible: {productized_lines}")
_assert("阵列间距:已开放待识别" in productized_lines, f"productized pattern.spacing should be visible: {productized_lines}")
_assert("局部间距:已开放待识别" in productized_lines, f"productized pattern.segment_spacing should be visible: {productized_lines}")
_assert("壳体厚度:已识别待验证" in planned_lines, f"planned shell.thickness candidate should be visible: {planned_lines}")
_assert("阵列实例位置:几何证据待分类" in planned_lines, f"geometry-only S7 hint should be visible: {planned_lines}")
_assert("阵列实例位置:已开放待识别" in productized_lines, f"productized pattern.instance_position should be visible: {productized_lines}")
_assert("壳体厚度:已开放待识别" in productized_lines, f"productized shell.thickness should be visible: {productized_lines}")
_assert("阵列实例位置" not in planned_lines, f"pattern.instance_position should no longer be a planned-only line: {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}")