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
+290 -1472
View File
File diff suppressed because it is too large Load Diff
@@ -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}") _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: def _verify_boss_summary(root: Path) -> None:
path = root / "boss.step" path = root / "boss.step"
_write_boss_model(path) _write_boss_model(path)
@@ -574,6 +621,7 @@ def main() -> int:
_verify_quick_cylinder_recognition(root) _verify_quick_cylinder_recognition(root)
_verify_hole_summary(root) _verify_hole_summary(root)
_verify_slot_summary(root) _verify_slot_summary(root)
_verify_split_cylinder_slot_and_hole_guard()
_verify_boss_summary(root) _verify_boss_summary(root)
_verify_asitus_slot_boss_fillet_hints(root) _verify_asitus_slot_boss_fillet_hints(root)
_verify_torus_summary(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_probe_job.json -> /RunScript 扫描 STEP]",
"[scdm_feature_cache.json -> 映射为本软件能力字典和中文参数]", "[scdm_feature_cache.json -> 映射为本软件能力字典和中文参数]",
"[参数化建模 -> 多个目标值统一提交,不再每行一个操作按钮]", "[参数化建模 -> 多个目标值统一提交,不再每行一个操作按钮]",
"[公式输入 -> 支持 Face85.直径 = Face87.半径",
"[批量联动 -> 多条公式先求值成一组目标参数",
"[关系式管理 -> 公式启停、删除回滚、基础单位字面量 mm/cm/m、JSON 导入/导出已接",
"[Face 偏移 -> face.offset / OffsetFaces]", "[Face 偏移 -> face.offset / OffsetFaces]",
"[槽宽 -> slot.width / OffsetFaces", "[槽宽 -> slot.width / OffsetFaces",
"[槽深 -> slot.depth / Move 或 OffsetFaces", "[槽深 -> slot.depth / Move 或 OffsetFaces",
@@ -96,7 +99,7 @@ def _verify_readme_mentions(readme: str) -> None:
def _verify_roadmap_scope(readme: str) -> None: def _verify_roadmap_scope(readme: str) -> None:
start_marker = "SCDM-first 主路线" start_marker = "SCDM-first 主路线"
end_marker = "└── 7. 交付与兜底边界" end_marker = "└── 8. 交付与兜底边界"
start = readme.find(start_marker) start = readme.find(start_marker)
end = readme.find(end_marker) end = readme.find(end_marker)
_assert(start >= 0 and end > start, "README should contain a single SCDM-first roadmap before fallback boundary") _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_probe_job.json -> /RunScript 扫描 STEP]",
"│ ├── [x] [scdm_feature_cache.json -> 映射为本软件能力字典和中文参数]", "│ ├── [x] [scdm_feature_cache.json -> 映射为本软件能力字典和中文参数]",
"├── 2. 能力字典、参数表和用户入口", "├── 2. 能力字典、参数表和用户入口",
"├── 3. SCDM 参数化建模执行", "├── 3. 关系式与参数联动",
"├── 4. 结果校验、回滚和 ID 续接", "[公式输入 -> 支持 Face85.直径 = Face87.半径 这类对象.参数表达式和补全]",
"├── 5. 当前已开放或正在开放的 SCDM 能力", "[添加守门 -> 阻止自引用、重复目标、循环依赖和当前无可执行参数的公式]",
"[批量联动 -> 多条公式先求值成一组目标参数,再合并为一个参数化建模任务]",
"├── 4. SCDM 参数化建模执行",
"├── 5. 结果校验、回滚和 ID 续接",
"├── 6. 当前已开放或正在开放的 SCDM 能力",
"[阵列间距 -> pattern.spacing / Move,整体阵列保持中心不变并等距重排;安全范围由支撑面动态计算,不针对 Face92 写死]", "[阵列间距 -> pattern.spacing / Move,整体阵列保持中心不变并等距重排;安全范围由支撑面动态计算,不针对 Face92 写死]",
"[局部间距 -> pattern.segment_spacing / Move,按“FaceA-FaceB 间距”或“零件A-零件B 间距”修改相邻段;已支持固定前项移动后侧、固定后项移动前侧、两侧均分保持中心]", "[局部间距 -> pattern.segment_spacing / Move,按“FaceA-FaceB 间距”或“零件A-零件B 间距”修改相邻段;已支持固定前项移动后侧、固定后项移动前侧、两侧均分保持中心、只移动前项、只移动后项]",
"├── 6. 下一批只按 SCDM 能力适配", "[阵列实例位置 -> pattern.instance_position / Move,只移动当前阵列成员;不自动保持整体阵列等距,真实 STEP 回测待补]",
"[阵列实例位置 -> pattern.instance_position]", "[壳体厚度 -> shell.thickness / Move,薄壁两平面配对后固定一侧、移动另一侧;真实 STEP 回测待补]",
"[壳体厚度 -> shell.thickness,薄壁候选配对已进 cache", "├── 7. 下一批只按 SCDM 能力适配",
"[SCDM 之外的新能力 -> 等 SCDM 能力适配完再评估]",
) )
for fragment in required_active_fragments: for fragment in required_active_fragments:
_assert(fragment in active_scope, f"README active roadmap missing: {fragment}") _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.widgets import NoWheelComboBox
from step_editor.relation_formulas import ObjectParameterRef, parse_relation_formula 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_actions import WindowActionMixin
from step_editor.window_core import WindowCoreMixin from step_editor.window_core import WindowCoreMixin
from step_editor.window_state import ( from step_editor.window_state import (
@@ -103,6 +105,8 @@ class _PropertyTableProbe(QWidget, WindowStateMixin):
"feature.delete_round_or_chamfer", "feature.delete_round_or_chamfer",
"pattern.spacing", "pattern.spacing",
"pattern.segment_spacing", "pattern.segment_spacing",
"pattern.instance_position",
"shell.thickness",
} }
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
@@ -142,6 +146,9 @@ class _PropertyTableProbe(QWidget, WindowStateMixin):
self.relation_formula_input.textChanged.connect(self._on_relation_formula_input_changed) self.relation_formula_input.textChanged.connect(self._on_relation_formula_input_changed)
layout.addWidget(self.relation_formula_input) layout.addWidget(self.relation_formula_input)
self.add_relation_formula_button = QPushButton() 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.remove_relation_formula_button = QPushButton()
self.relation_formula_list = QListWidget() self.relation_formula_list = QListWidget()
self.face_width_input = QLineEdit() 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") _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: def _assert_relation_formula_input_is_selection_independent() -> None:
probe = _RelationFormulaEventProbe() probe = _RelationFormulaEventProbe()
probe.relation_formula_input.installEventFilter(probe) 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}") _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: def _assert_solid_selection_does_not_expand_face_scdm_specs() -> None:
probe = _PropertyTableProbe() probe = _PropertyTableProbe()
probe.scdm_feature_cache_state = "ready" 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", "STEP load should restore a matching SCDM disk cache before launching a new probe",
) )
_assert( _assert(
"_defer_large_model_recognition_preloads" in loaded_body, "_defer_post_import_recognition_preloads" in loaded_body
"large model loads should defer full external recognition preloads by default", 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( _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", "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") preload_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_start_scdm_probe_preload")
_assert( _assert(
"_current_scdm_feature_cache_matches_loaded_step()" in preload_body, "_current_scdm_feature_cache_matches_loaded_step()" in preload_body,
"SCDM preload should skip relaunching SpaceClaim when a current cache is already installed", "SCDM preload should skip relaunching SpaceClaim when a current cache is already installed",
) )
_assert( _assert(
"local_face_signatures = self._scdm_local_face_signatures()" not in preload_body, "model=model" not in preload_body
"SCDM preload must not build all local Face signatures on the UI thread", 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( _assert(
"force: bool = False" in preload_body "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", "large-model SCDM preload should be deferred unless a validation path forces it",
) )
_assert( _assert(
'builder = getattr(model, "scdm_local_face_signatures", None)' in preload_body, 'builder = getattr(model, "scdm_local_face_signatures", None)' not in preload_body,
"SCDM preload should build local Face signatures inside the background worker", "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") 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") _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_disabled" in hover_body,
"large-model hover picking should be suppressed before the VTK picker runs", "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") action_body = _function_text(PROJECT_ROOT / "step_editor/window_actions.py", "_isolation_for_plan")
_assert( _assert(
"_prefer_isolated_process_for_large_interactive_edit" in action_body "_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: def _assert_large_planar_offset_prefers_local_backend() -> None:
probe = _PropertyTableProbe() probe = _PropertyTableProbe()
probe.model = _LargeDisplayModel() 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 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 edits")
_assert("## 暂不能稳定识别" in progress_detail, "software progress dialog text should list unsupported recognition") _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("孔组" 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("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-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_radius_formula_proxy()
_assert_relation_formula_input_remains_editable_while_replaying() _assert_relation_formula_input_remains_editable_while_replaying()
_assert_relation_formula_input_clickable_after_existing_formula() _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_is_selection_independent()
_assert_relation_formula_input_recovers_after_loading() _assert_relation_formula_input_recovers_after_loading()
_assert_relation_formula_ids_follow_model_remap() _assert_relation_formula_ids_follow_model_remap()
_assert_mouse_selection_guards() _assert_mouse_selection_guards()
_assert_scdm_selection_diagnostics() _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_solid_selection_does_not_expand_face_scdm_specs()
_assert_operation_record_backend_sources() _assert_operation_record_backend_sources()
_assert_scdm_auto_prompt() _assert_scdm_auto_prompt()
@@ -2092,6 +2546,8 @@ def main() -> int:
_assert_worker_ui_callbacks_guarded() _assert_worker_ui_callbacks_guarded()
_assert_large_model_preload_stays_lightweight() _assert_large_model_preload_stays_lightweight()
_assert_large_model_selection_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_large_planar_offset_prefers_local_backend()
_assert_background_load_uses_worker() _assert_background_load_uses_worker()
_assert_quick_blind_depth_spec() _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 from step_editor.relation_formulas import ( # noqa: E402
RelationFormulaError, RelationFormulaError,
Vector3,
evaluate_relation_formula,
parse_relation_formula, parse_relation_formula,
validate_relation_formula_graph, 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}") 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: def main() -> int:
_assert_ok(["Face87.直径 = Face87.半径 + 0.1"]) _assert_ok(["Face87.直径 = Face87.半径 + 0.1"])
_assert_ok(["Face87.位置 = Face85.位置 + (0, 0, -3.5)"]) _assert_ok(["Face87.位置 = Face85.位置 + (0, 0, -3.5)"])
_assert_ok(["Face85.直径 = Face87.半径", "Face11.直径 = Face85.半径"]) _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(["Face87.直径 = Face87.直径 + 0.1"], "不能引用自身")
_assert_fails(["Face85.直径 = Face87.半径", "Face85.直径 = Face11.半径"], "同一目标参数") _assert_fails(["Face85.直径 = Face87.半径", "Face85.直径 = Face11.半径"], "同一目标参数")
+238 -13
View File
@@ -175,10 +175,15 @@ def main() -> int:
"change_boss_diameter", "change_boss_diameter",
"change_round_radius", "change_round_radius",
"change_chamfer_distance", "change_chamfer_distance",
"change_shell_thickness",
"change_pattern_spacing", "change_pattern_spacing",
"change_pattern_segment_spacing", "change_pattern_segment_spacing",
"move_pattern_instance",
"segment_before", "segment_before",
"segment_split", "segment_split",
"segment_single_left",
"segment_single_right",
"wallFaceLocators",
"pull_face_offset", "pull_face_offset",
"fill_feature", "fill_feature",
"delete_round_or_chamfer", "delete_round_or_chamfer",
@@ -207,6 +212,11 @@ def main() -> int:
): ):
_assert(token in script, f"generated edit script missing {token}") _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("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) 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") _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("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("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}") _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( slot_depth_prepared = prepare_scdm_edit_job(
step_path, step_path,
output_dir=root / "slot-depth-prepared", 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("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("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}") _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 = { boss_signature = {
"objectType": "cylindrical_boss", "objectType": "cylindrical_boss",
@@ -316,6 +355,22 @@ def main() -> int:
boss_height_signature = boss_height_job.get("object", {}).get("geometrySignature", {}) 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("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}") _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( boss_diameter_prepared = prepare_scdm_edit_job(
step_path, step_path,
output_dir=root / "boss-diameter-prepared", output_dir=root / "boss-diameter-prepared",
@@ -332,6 +387,48 @@ def main() -> int:
boss_diameter_signature = boss_diameter_job.get("object", {}).get("geometrySignature", {}) 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("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}") _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 = { round_signature = {
"objectType": "round", "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("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}") _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 = { pattern_signature = {
"objectType": "linear_pattern", "objectType": "linear_pattern",
"faceIds": [85, 94, 87, 96, 89, 98], "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", 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}", 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 = { body_pattern_signature = {
"objectType": "linear_pattern", "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}", 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( unsupported = prepare_scdm_edit_job(
step_path, step_path,
output_dir=root / "unsupported-unknown", 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("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}") _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( pattern_spacing_success = run_scdm_edit_job(
step_path, step_path,
output_dir=root / "pattern-spacing-success", output_dir=root / "pattern-spacing-success",
@@ -850,6 +1063,18 @@ def main() -> int:
pattern_segment_success.get("backend_operation") == "change_pattern_segment_spacing", pattern_segment_success.get("backend_operation") == "change_pattern_segment_spacing",
f"pattern.segment_spacing should report change_pattern_segment_spacing: {pattern_segment_success}", 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( missing_output = run_scdm_edit_job(
step_path, step_path,
+181 -13
View File
@@ -326,6 +326,42 @@ def main() -> int:
], ],
"rawLimitations": [], "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", "backendId": "slot:1",
"objectType": "slot", "objectType": "slot",
@@ -549,6 +585,36 @@ def main() -> int:
_assert(isinstance(round_object, dict), f"round object should be normalized: {cache}") _assert(isinstance(round_object, dict), f"round object should be normalized: {cache}")
round_signature = round_object.get("geometrySignature") 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}") _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"}) round_specs = property_specs_from_scdm_cache(cache, selected_face_ids=(60,), execution_ready={"round.radius", "feature.delete_round_or_chamfer"})
_assert( _assert(
{str(spec.get("scdm_capability_key")) for spec in round_specs} == {"round.radius", "feature.delete_round_or_chamfer"}, {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) 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(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}") _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 = { missing_current_raw = {
"schemaVersion": 1, "schemaVersion": 1,
"backend": {"name": "SCDM", "version": "v222"}, "backend": {"name": "SCDM", "version": "v222"},
@@ -1052,10 +1168,10 @@ def main() -> int:
f"SCDM component occurrence diagnostics should be preserved: {cache}", f"SCDM component occurrence diagnostics should be preserved: {cache}",
) )
geometry_hints = diagnostics.get("geometry_candidate_hints") 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)} hint_keys = {str(item.get("capabilityKey")) for item in geometry_hints if isinstance(item, dict)}
_assert( _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}", f"S7 geometry hints should cover planned feature families: {hint_keys}",
) )
derived_candidates = diagnostics.get("derived_feature_candidates") 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("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(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(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( body_pattern_candidate = next(
( (
item item
@@ -1097,7 +1218,7 @@ def main() -> int:
body_instances = body_pattern_signature.get("patternInstances") or [] 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(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}") _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 = [ body_pattern_spacing = [
spec for spec in body_pattern_specs if spec.get("scdm_capability_key") == "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(len(body_segment_specs) == 2, f"three body pattern members should expose two local spacing segments: {body_pattern_specs}")
_assert( _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("enabled") is True
and body_segment_specs[0].get("scdm_backend_operation") == "change_pattern_segment_spacing", 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}", 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") body_segment_modes = body_segment_specs[0].get("scope_modes")
_assert( _assert(
isinstance(body_segment_modes, dict) 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}", f"local pattern spacing should expose several explicit modeling intents: {body_segment_specs}",
) )
_assert( _assert(
body_segment_modes["fix_left_move_right"].get("enabled") is True 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["fix_right_move_left"].get("enabled") is True
and body_segment_modes["split_keep_center"].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, and body_segment_modes["move_single_left"].get("enabled") is True
f"implemented and blocked local spacing intents should be explicit: {body_segment_modes}", 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(): for mode in body_segment_modes.values():
if not isinstance(mode, dict): if not isinstance(mode, dict):
@@ -1135,6 +1257,11 @@ def main() -> int:
label = str(mode.get("label") or "").strip() 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() 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(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( _assert(
"固定前项" in str(body_segment_specs[0].get("scope_text") or "") "固定前项" in str(body_segment_specs[0].get("scope_text") or "")
and "移动后侧" 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", and body_segment_signature.get("movingSide") == "after",
f"local segment specs should carry segment execution metadata: {body_segment_specs}", 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) 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 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}") _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"), 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}", 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") not_productized = diagnostics.get("discovered_not_productized")
_assert(isinstance(not_productized, list) and not_productized, f"non-productized candidates should stay diagnostic only: {cache}") _assert(isinstance(not_productized, list) and not_productized, f"non-productized candidates should stay diagnostic only: {cache}")
planned = diagnostics.get("planned_not_productized") planned = diagnostics.get("planned_not_productized")
_assert(isinstance(planned, list), f"planned diagnostics should be present: {cache}") _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)} planned_keys = {str(item.get("capabilityKey")) for item in planned if isinstance(item, dict)}
expected_planned = { _assert("pattern.instance_position" not in planned_keys, f"pattern.instance_position should now be productized with member-level specs: {planned_keys}")
"pattern.instance_position", _assert("shell.thickness" not in planned_keys, f"shell.thickness should now be productized with strict geometry gates: {planned_keys}")
"shell.thickness",
}
_assert(expected_planned <= planned_keys, f"S7 planned capabilities should be diagnosed but not productized: {planned_keys}")
raw_file = root / "raw.json" raw_file = root / "raw.json"
cache_file = root / "cache.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", 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}", 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( shell_thickness_ok = check_scdm_target(
{ {
"objectType": "thin_wall", "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") 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}") _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( summary_after_bad = _cache_with_summary(
{"bodyCount": 13, "objectCount": 80, "faceCount": 20, "edgeCount": 45}, {"bodyCount": 13, "objectCount": 80, "faceCount": 20, "edgeCount": 45},
_hole("hole:90", 90, diameter=0.75, center=(0.5, 1.0, 9.5)), _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}, "geometrySignature": {"spacing": 5.0, "instanceCount": 3},
} }
], ],
"planned_not_productized": [ "planned_not_productized": [],
{"capabilityKey": "shell.thickness", "objectType": "shell"},
],
"discovered_not_productized": [ "discovered_not_productized": [
{"objectType": "mystery_feature"}, {"objectType": "mystery_feature"},
{"objectType": "mystery_feature"}, {"objectType": "mystery_feature"},
@@ -140,14 +138,16 @@ def main() -> int:
"feature.delete_round_or_chamfer", "feature.delete_round_or_chamfer",
"pattern.spacing", "pattern.spacing",
"pattern.segment_spacing", "pattern.segment_spacing",
"pattern.instance_position",
"shell.thickness",
}, },
) )
summary = progress.get("summary") summary = progress.get("summary")
_assert(isinstance(summary, dict), f"capability progress should include summary: {progress}") _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("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") == 15, f"runner-ready capability count should honor UI gate: {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("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("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("faceAdjacency") == 2 and summary.get("circularEdges") == 4, f"probe topology evidence should be counted: {summary}")
_assert( _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 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.spacing should be visible: {productized_lines}")
_assert("局部间距:已开放待识别" in productized_lines, f"productized pattern.segment_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 productized_lines, f"productized pattern.instance_position should be visible: {productized_lines}")
_assert("阵列实例位置:几何证据待分类" in planned_lines, f"geometry-only S7 hint should be visible: {planned_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("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("对象分布" 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("几何候选 凸台高度:3" in evidence_lines, f"probe geometry hint lines should be readable: {evidence_lines}")
+15
View File
@@ -228,6 +228,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.face_overlay_polydata_cache: dict[tuple[tuple[int, ...] | None, tuple[int, ...] | None, bool], object] = {} self.face_overlay_polydata_cache: dict[tuple[tuple[int, ...] | None, tuple[int, ...] | None, bool], object] = {}
self.edge_overlay_polydata_cache: dict[int, object] = {} self.edge_overlay_polydata_cache: dict[int, object] = {}
self.overlay_cache_limit = 160 self.overlay_cache_limit = 160
self.scene_rebuild_in_progress = False
self.show_internal_edges_checkbox: QCheckBox | None = None self.show_internal_edges_checkbox: QCheckBox | None = None
self.scene_isolated = False self.scene_isolated = False
self.undo_stack: list[dict[int, object]] = [] self.undo_stack: list[dict[int, object]] = []
@@ -290,6 +291,8 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
"feature.delete_round_or_chamfer", "feature.delete_round_or_chamfer",
"pattern.spacing", "pattern.spacing",
"pattern.segment_spacing", "pattern.segment_spacing",
"pattern.instance_position",
"shell.thickness",
} }
self.load_in_progress = False self.load_in_progress = False
self.load_thread: QThread | None = None self.load_thread: QThread | None = None
@@ -1409,7 +1412,19 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
relation_layout.addWidget(self.relation_formula_list) relation_layout.addWidget(self.relation_formula_list)
relation_button_row = QHBoxLayout() relation_button_row = QHBoxLayout()
relation_button_row.setContentsMargins(0, 0, 0, 0) relation_button_row.setContentsMargins(0, 0, 0, 0)
self.import_relation_formula_button = QPushButton("导入公式")
self.import_relation_formula_button.setObjectName("relationFormulaImportButton")
self.import_relation_formula_button.clicked.connect(self.import_relation_formulas)
relation_button_row.addWidget(self.import_relation_formula_button)
self.export_relation_formula_button = QPushButton("导出公式")
self.export_relation_formula_button.setObjectName("relationFormulaExportButton")
self.export_relation_formula_button.clicked.connect(self.export_relation_formulas)
relation_button_row.addWidget(self.export_relation_formula_button)
relation_button_row.addStretch(1) relation_button_row.addStretch(1)
self.toggle_relation_formula_button = QPushButton("停用公式")
self.toggle_relation_formula_button.setObjectName("relationFormulaToggleButton")
self.toggle_relation_formula_button.clicked.connect(self.toggle_selected_relation_formula)
relation_button_row.addWidget(self.toggle_relation_formula_button)
self.remove_relation_formula_button = QPushButton("删除公式") self.remove_relation_formula_button = QPushButton("删除公式")
self.remove_relation_formula_button.setObjectName("relationFormulaRemoveButton") self.remove_relation_formula_button.setObjectName("relationFormulaRemoveButton")
self.remove_relation_formula_button.clicked.connect(self.remove_selected_relation_formula) self.remove_relation_formula_button.clicked.connect(self.remove_selected_relation_formula)
+75 -9
View File
@@ -479,6 +479,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
"same_domain_v_range", "same_domain_v_range",
"same_domain_height_estimate", "same_domain_height_estimate",
"same_domain_range_source", "same_domain_range_source",
"selected_angular_span",
): ):
if key in hint: if key in hint:
info[key] = hint[key] info[key] = hint[key]
@@ -656,7 +657,33 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
if angular_span is None: if angular_span is None:
angular_span = _float_or_none(info.get("angular_span")) or 0.0 angular_span = _float_or_none(info.get("angular_span")) or 0.0
is_full = bool(info.get("is_full_cylinder")) or angular_span >= math.tau * 0.92 is_full = bool(info.get("is_full_cylinder")) or angular_span >= math.tau * 0.92
selected_span = (
_float_or_none(info.get("selected_angular_span"))
or _float_or_none(info.get("slot_angular_span"))
or _float_or_none(info.get("angular_span"))
or angular_span
)
try:
same_domain_count = int(info.get("same_domain_face_count", 1) or 1)
except (TypeError, ValueError):
same_domain_count = 1
is_blind_split_groove = (
is_full
and same_domain_count > 1
and 1e-6 < selected_span < math.tau * 0.92
and str(info.get("cylinder_end_type") or "") == "blind"
and guess == "hole/groove candidate"
)
if guess == "hole/groove candidate": if guess == "hole/groove candidate":
if is_blind_split_groove:
return {
"feature_type": "圆柱孔/槽候选",
"feature_edit_actions": (
"调整圆柱孔径或槽/凹槽宽度、深度;"
"具体按孔还是按槽会在完整识别时根据周围同类特征确认。"
),
"blind_split_cylindrical_pocket": True,
}
if not is_full: if not is_full:
return { return {
"feature_type": "槽/半孔候选", "feature_type": "槽/半孔候选",
@@ -861,6 +888,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
"same_domain_face_count": len(side_face_ids), "same_domain_face_count": len(side_face_ids),
"feature_highlight_face_ids": tuple(side_face_ids), "feature_highlight_face_ids": tuple(side_face_ids),
"angular_span": combined_span, "angular_span": combined_span,
"selected_angular_span": selected_span,
"same_domain_angular_span": combined_span, "same_domain_angular_span": combined_span,
"same_domain_note": same_domain_note, "same_domain_note": same_domain_note,
"is_full_cylinder": combined_span >= math.tau * 0.92, "is_full_cylinder": combined_span >= math.tau * 0.92,
@@ -2813,6 +2841,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
bottom_face_ids = end_faces["bottom_face_ids"] bottom_face_ids = end_faces["bottom_face_ids"]
opening_face_ids = end_faces["opening_face_ids"] opening_face_ids = end_faces["opening_face_ids"]
guess = str(info.get("feature_guess", "cylindrical face")) guess = str(info.get("feature_guess", "cylindrical face"))
selected_angular_span = _float_or_none(info.get("angular_span")) or combined_angular_span
has_two_axial_caps = bool(end_faces["start_end_face_ids"] and end_faces["end_end_face_ids"]) has_two_axial_caps = bool(end_faces["start_end_face_ids"] and end_faces["end_end_face_ids"])
support_face_ids_for_round = sorted(set(adjacent_face_ids) - set(end_face_ids)) support_face_ids_for_round = sorted(set(adjacent_face_ids) - set(end_face_ids))
material_toward = str(info.get("material_toward_axis", "") or "") material_toward = str(info.get("material_toward_axis", "") or "")
@@ -2832,13 +2861,37 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
"cocylindrical region has material inside its axis and explicit planar caps at both ends" "cocylindrical region has material inside its axis and explicit planar caps at both ends"
) )
domain_info["feature_guess"] = info["feature_guess"] domain_info["feature_guess"] = info["feature_guess"]
slot_info = self._cylindrical_slot_info(face_id, adjacent_face_ids, end_face_ids, domain_info) is_blind_split_cylindrical_pocket = (
guess == "hole/groove candidate"
and len(side_face_ids) > 1
and combined_angular_span >= math.tau * 0.92
and 1e-6 < selected_angular_span < math.tau * 0.92
and str(info.get("cylinder_end_type") or "") == "blind"
and bool(bottom_face_ids)
and bool(opening_face_ids)
)
slot_domain_info = dict(domain_info)
if is_blind_split_cylindrical_pocket:
slot_domain_info["angular_span"] = selected_angular_span
slot_domain_info["is_full_cylinder"] = False
slot_info = self._cylindrical_slot_info(face_id, adjacent_face_ids, end_face_ids, slot_domain_info)
fillet_info = self._cylindrical_existing_fillet_info(face_id, adjacent_face_ids, end_face_ids, domain_info) fillet_info = self._cylindrical_existing_fillet_info(face_id, adjacent_face_ids, end_face_ids, domain_info)
try:
slot_pair_candidate_count = int(slot_info.get("slot_pair_candidate_count", 0) or 0)
except (TypeError, ValueError):
slot_pair_candidate_count = 0
is_blind_split_cylindrical_groove = (
is_blind_split_cylindrical_pocket
and slot_pair_candidate_count >= 10
)
guess = str(info.get("feature_guess", "cylindrical face")) guess = str(info.get("feature_guess", "cylindrical face"))
angular_span = combined_angular_span angular_span = selected_angular_span if is_blind_split_cylindrical_groove else combined_angular_span
if guess == "hole/groove candidate": if guess == "hole/groove candidate":
if angular_span < math.tau * 0.92: if is_blind_split_cylindrical_groove:
feature_type = "盲槽/圆柱凹槽候选"
edit_actions = "调整槽/凹槽宽度;调整槽/凹槽深度;调整圆弧长度;调整圆弧角度"
elif angular_span < math.tau * 0.92:
feature_type = "槽/半孔候选" feature_type = "槽/半孔候选"
edit_actions = "调整圆柱孔径;调整槽/半孔宽度;调整槽/半孔深度;调整槽/半孔圆弧长度;调整槽/半孔圆弧角度;调整槽孔总长度" edit_actions = "调整圆柱孔径;调整槽/半孔宽度;调整槽/半孔深度;调整槽/半孔圆弧长度;调整槽/半孔圆弧角度;调整槽孔总长度"
else: else:
@@ -2848,7 +2901,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
edit_actions += ";调整盲孔/盲槽深度" edit_actions += ";调整盲孔/盲槽深度"
else: else:
edit_actions += ";孔深调整需要明确盲孔底面" edit_actions += ";孔深调整需要明确盲孔底面"
if angular_span >= math.tau * 0.92: if angular_span >= math.tau * 0.92 and not is_blind_split_cylindrical_groove:
edit_actions += ";封堵圆柱孔" edit_actions += ";封堵圆柱孔"
elif guess == "round/fillet candidate": elif guess == "round/fillet candidate":
fillet_status = str(fillet_info.get("existing_fillet_status") or "") fillet_status = str(fillet_info.get("existing_fillet_status") or "")
@@ -2868,7 +2921,9 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
else: else:
feature_type = "未明确圆柱特征" feature_type = "未明确圆柱特征"
edit_actions = "可尝试调整圆柱孔径,但风险较高。" edit_actions = "可尝试调整圆柱孔径,但风险较高。"
if str(slot_info.get("slot_status") or "") == "blocked": if str(slot_info.get("slot_status") or "") == "blocked" and (
is_blind_split_cylindrical_groove or angular_span < math.tau * 0.92
):
feature_type = "复杂槽/多槽组候选(暂不支持修改)" feature_type = "复杂槽/多槽组候选(暂不支持修改)"
edit_actions = "只读诊断;当前不开放槽宽、槽深、弧长、弧角或槽轴心修改。" edit_actions = "只读诊断;当前不开放槽宽、槽深、弧长、弧角或槽轴心修改。"
@@ -2900,7 +2955,8 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
"same_domain_height_estimate": axis_range["span"], "same_domain_height_estimate": axis_range["span"],
"same_domain_angular_span": combined_angular_span, "same_domain_angular_span": combined_angular_span,
"angular_span": combined_angular_span, "angular_span": combined_angular_span,
"is_full_cylinder": combined_angular_span >= math.tau * 0.92, "is_full_cylinder": combined_angular_span >= math.tau * 0.92 and not is_blind_split_cylindrical_groove,
"blind_split_cylindrical_groove": is_blind_split_cylindrical_groove,
"same_domain_range_source": axis_range["range_source"], "same_domain_range_source": axis_range["range_source"],
"same_domain_face_ids": tuple(side_face_ids), "same_domain_face_ids": tuple(side_face_ids),
"same_domain_face_count": len(side_face_ids), "same_domain_face_count": len(side_face_ids),
@@ -2921,7 +2977,15 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
} }
) )
result.update(self._asitus_cylindrical_feature_hint_fields(face_id, result)) result.update(self._asitus_cylindrical_feature_hint_fields(face_id, result))
if angular_span >= math.tau * 0.92: if is_blind_split_cylindrical_groove:
result["angular_span"] = selected_angular_span
result.setdefault("slot_kind", "partial-cylindrical-groove")
result.setdefault("slot_status", "candidate")
result.setdefault(
"slot_note",
"同域碎面合并后接近完整圆柱,但当前对象只有一个开口和一个底面,按盲槽/圆柱凹槽处理。",
)
elif angular_span >= math.tau * 0.92:
result.update( result.update(
{ {
"slot_kind": "", "slot_kind": "",
@@ -2938,7 +3002,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
} }
) )
scoped_readiness_info = dict(result) scoped_readiness_info = dict(result)
scoped_readiness_info["angular_span"] = combined_angular_span scoped_readiness_info["angular_span"] = angular_span
scoped_readiness_info["height_estimate"] = axis_range["span"] scoped_readiness_info["height_estimate"] = axis_range["span"]
scoped_readiness_info["feature_guess"] = guess scoped_readiness_info["feature_guess"] = guess
if guess == "hole/groove candidate": if guess == "hole/groove candidate":
@@ -2966,7 +3030,9 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
"resize_note": "当前对象已识别为圆角/倒圆;请使用已有圆角半径入口,不按孔径重切。", "resize_note": "当前对象已识别为圆角/倒圆;请使用已有圆角半径入口,不按孔径重切。",
} }
) )
if str(slot_info.get("slot_status") or "") == "blocked": if str(slot_info.get("slot_status") or "") == "blocked" and (
is_blind_split_cylindrical_groove or angular_span < math.tau * 0.92
):
blocker = str(slot_info.get("slot_blockers") or "当前槽/半孔属于复杂槽或多槽组,暂不开放稳定修改。") blocker = str(slot_info.get("slot_blockers") or "当前槽/半孔属于复杂槽或多槽组,暂不开放稳定修改。")
result.update( result.update(
{ {
+3 -1
View File
@@ -165,7 +165,9 @@ def feature_recognition_priority(info: Mapping[str, object]) -> int:
not _is_effectively_full_cylinder(info) not _is_effectively_full_cylinder(info)
and angular_span is not None and angular_span is not None
and angular_span < math.tau * 0.92 and angular_span < math.tau * 0.92
) or _text(info.get("slot_kind")) == "partial-cylindrical-groove" or "槽/半孔候选" in feature_type: ) or _text(info.get("slot_kind")) == "partial-cylindrical-groove" or any(
token in feature_type for token in ("槽/半孔候选", "盲槽", "凹槽")
):
return 30 return 30
return 20 return 20
if feature_guess == "boss/outer-round candidate": if feature_guess == "boss/outer-round candidate":
+31
View File
@@ -10,6 +10,18 @@ from typing import Callable, Iterable
RELATION_REF_PATTERN = re.compile( RELATION_REF_PATTERN = re.compile(
r"\b(?P<kind>Face|Edge)(?P<object_id>\d+)\.(?P<parameter>[A-Za-z0-9_\u4e00-\u9fff]+)\b" r"\b(?P<kind>Face|Edge)(?P<object_id>\d+)\.(?P<parameter>[A-Za-z0-9_\u4e00-\u9fff]+)\b"
) )
RELATION_UNIT_LITERAL_PATTERN = re.compile(
r"(?<![A-Za-z0-9_.])(?P<number>(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)\s*(?P<unit>mm|毫米|cm|厘米|m|米)\b",
re.IGNORECASE,
)
RELATION_UNIT_MULTIPLIERS = {
"mm": 1.0,
"毫米": 1.0,
"cm": 10.0,
"厘米": 10.0,
"m": 1000.0,
"": 1000.0,
}
class RelationFormulaError(ValueError): class RelationFormulaError(ValueError):
@@ -112,7 +124,10 @@ def parse_relation_formula(text: str) -> RelationFormula:
references.append(_ref_from_match(match)) references.append(_ref_from_match(match))
return f"__ref{len(references) - 1}" return f"__ref{len(references) - 1}"
# 用户输入的 Face85.直径 不能直接丢给 eval;先替换成内部占位符,
# 后面只允许这些占位符和白名单 AST 节点参与计算。
safe_expression = RELATION_REF_PATTERN.sub(replace_ref, expression) safe_expression = RELATION_REF_PATTERN.sub(replace_ref, expression)
safe_expression = _replace_unit_literals(safe_expression)
try: try:
tree = ast.parse(safe_expression, mode="eval") tree = ast.parse(safe_expression, mode="eval")
except SyntaxError as exc: except SyntaxError as exc:
@@ -136,6 +151,8 @@ def evaluate_relation_formula(
namespace[f"__ref{index}"] = _coerce_formula_value(value_resolver(ref)) namespace[f"__ref{index}"] = _coerce_formula_value(value_resolver(ref))
code = compile(formula.safe_expression, "<relation-formula>", "eval") code = compile(formula.safe_expression, "<relation-formula>", "eval")
try: try:
# 这里仍然使用 Python 表达式能力,但 builtins 为空,AST 也已校验过。
# 关系式只承担参数求值,不允许调用函数、访问属性或执行任意代码。
value = eval(code, {"__builtins__": {}}, namespace) value = eval(code, {"__builtins__": {}}, namespace)
except ZeroDivisionError as exc: except ZeroDivisionError as exc:
raise RelationFormulaError("关系式中出现除以 0。") from exc raise RelationFormulaError("关系式中出现除以 0。") from exc
@@ -158,6 +175,8 @@ def validate_relation_formula_graph(formulas: Iterable[RelationFormula]) -> None
reference_tokens = [ref.token for ref in formula.references] reference_tokens = [ref.token for ref in formula.references]
if target_token in reference_tokens: if target_token in reference_tokens:
raise RelationFormulaError(f"关系式不能引用自身:{target_token}") raise RelationFormulaError(f"关系式不能引用自身:{target_token}")
# 只把“由其它公式控制的参数”纳入依赖图;普通测量值由模型/cache 提供,
# 不参与循环依赖判断。
graph[target_token] = [ref_token for ref_token in reference_tokens if ref_token in target_tokens] graph[target_token] = [ref_token for ref_token in reference_tokens if ref_token in target_tokens]
visit_state: dict[str, str] = {} visit_state: dict[str, str] = {}
@@ -213,6 +232,18 @@ def _ref_from_match(match: re.Match[str]) -> ObjectParameterRef:
) )
def _replace_unit_literals(expression: str) -> str:
def replace(match: re.Match[str]) -> str:
number_text = str(match.group("number"))
unit = str(match.group("unit"))
multiplier = RELATION_UNIT_MULTIPLIERS.get(unit) or RELATION_UNIT_MULTIPLIERS.get(unit.lower())
if multiplier is None:
raise RelationFormulaError(f"不支持的单位:{unit}")
return f"({number_text}*{multiplier:.12g})"
return RELATION_UNIT_LITERAL_PATTERN.sub(replace, expression)
def _validate_expression_tree(tree: ast.AST, ref_count: int) -> None: def _validate_expression_tree(tree: ast.AST, ref_count: int) -> None:
allowed = ( allowed = (
ast.Expression, ast.Expression,
+5 -5
View File
@@ -36,6 +36,8 @@ class ScdmCapabilityDefinition:
} }
# 产品能力字典:SCDM raw 对象只有进入这里,才会被翻译成客户可见的中文参数。
# 新能力要同时补 backend_operation、post_check 和验证脚本,避免只显示不能执行的参数。
CAPABILITY_DEFINITIONS: dict[str, ScdmCapabilityDefinition] = { CAPABILITY_DEFINITIONS: dict[str, ScdmCapabilityDefinition] = {
"hole.diameter": ScdmCapabilityDefinition( "hole.diameter": ScdmCapabilityDefinition(
key="hole.diameter", key="hole.diameter",
@@ -226,9 +228,8 @@ CAPABILITY_DEFINITIONS: dict[str, ScdmCapabilityDefinition] = {
default_intent="移动阵列实例", default_intent="移动阵列实例",
backend_operation="move_pattern_instance", backend_operation="move_pattern_instance",
post_check="target_pattern_instance_center", post_check="target_pattern_instance_center",
productized=False, required_backend_command_groups=(("Move",),),
roadmap_stage="S7.5", roadmap_stage="S7.5",
block_reason="阵列实例位置属于 S7 第五批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。",
), ),
"shell.thickness": ScdmCapabilityDefinition( "shell.thickness": ScdmCapabilityDefinition(
key="shell.thickness", key="shell.thickness",
@@ -236,12 +237,11 @@ CAPABILITY_DEFINITIONS: dict[str, ScdmCapabilityDefinition] = {
object_types=("shell", "thin_wall"), object_types=("shell", "thin_wall"),
value_kind="number", value_kind="number",
current_fields=("geometry.thickness",), current_fields=("geometry.thickness",),
default_intent="修改壳体厚度", default_intent="固定一侧,移动另一侧",
backend_operation="change_shell_thickness", backend_operation="change_shell_thickness",
post_check="target_shell_thickness", post_check="target_shell_thickness",
productized=False, required_backend_command_groups=(("Move",),),
roadmap_stage="S7.5", roadmap_stage="S7.5",
block_reason="壳体厚度属于 S7 第五批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。",
), ),
} }
+357 -9
View File
@@ -50,6 +50,8 @@ def prepare_scdm_edit_job(
"roadmap_stage": definition.roadmap_stage, "roadmap_stage": definition.roadmap_stage,
} }
converted_target = _target_value_for_job(target_value, value_kind=definition.value_kind) converted_target = _target_value_for_job(target_value, value_kind=definition.value_kind)
# 先在本软件侧做快速守门,避免为了明显非法或证据不足的输入启动 SCDM,
# 否则用户会等很久才得到一个本可提前说明的失败。
preflight = _preflight_scdm_edit_job( preflight = _preflight_scdm_edit_job(
definition.key, definition.key,
converted_target, converted_target,
@@ -74,8 +76,11 @@ def prepare_scdm_edit_job(
script_path = work_dir / "scdm_edit.py" script_path = work_dir / "scdm_edit.py"
operation = backend_operation or definition.backend_operation operation = backend_operation or definition.backend_operation
# 旧 result.step/error.json 不能留着复用;否则 SCDM 启动失败时可能被误判为成功。
_clear_stale_outputs((output_path, result_path, error_path)) _clear_stale_outputs((output_path, result_path, error_path))
# 这是本软件和 SCDM 的后台协议。UI 不直接调用 SCDM API,
# 而是写 job JSON + 临时脚本,再用 SpaceClaim.exe /RunScript 执行。
job = { job = {
"schemaVersion": SCDM_EDIT_SCHEMA_VERSION, "schemaVersion": SCDM_EDIT_SCHEMA_VERSION,
"adapter": SCDM_EDIT_ADAPTER, "adapter": SCDM_EDIT_ADAPTER,
@@ -210,6 +215,8 @@ def run_prepared_scdm_edit_job(
command = scdm_run_script_command(backend.path, script_path) command = scdm_run_script_command(backend.path, script_path)
run = runner or subprocess.run run = runner or subprocess.run
try: try:
# SpaceClaim 是外部 GUI/几何进程,必须独立启动并加超时;
# Python/Qt 主进程只等待结果文件,避免 SCDM 卡死时拖垮界面。
completed = run( completed = run(
command, command,
cwd=str(script_path.parent), cwd=str(script_path.parent),
@@ -286,9 +293,17 @@ def _preflight_scdm_edit_job(
target_value: object, target_value: object,
object_signature: Mapping[str, object], object_signature: Mapping[str, object],
) -> dict[str, object]: ) -> dict[str, object]:
basic = _preflight_basic_target(capability_key, target_value, object_signature)
if basic.get("ok") is False:
return basic
required = _preflight_required_geometry(capability_key, object_signature)
if required.get("ok") is False:
return required
if capability_key not in {"pattern.spacing", "pattern.segment_spacing"}: if capability_key not in {"pattern.spacing", "pattern.segment_spacing"}:
return {"ok": True, "reason": "ok"} return {"ok": True, "reason": "ok"}
if _is_body_pattern_signature(object_signature): if _is_body_pattern_signature(object_signature):
# 实体/组件阵列必须有 occurrence locator。只有 bodyIndex 时移动的可能是共享定义,
# 不是用户眼里那个独立阵列成员。
if not _body_pattern_has_component_locators(object_signature): if not _body_pattern_has_component_locators(object_signature):
return { return {
"ok": False, "ok": False,
@@ -345,6 +360,203 @@ def _preflight_scdm_edit_job(
} }
def _preflight_basic_target(
capability_key: str,
target_value: object,
object_signature: Mapping[str, object],
) -> dict[str, object]:
if capability_key in _positive_number_capabilities():
target = _float_or_none(target_value)
if target is not None and target <= 0:
return {
"ok": False,
"reason": "target-value-illegal",
"message": "目标值必须大于 0。",
}
current_number = _current_number_for_capability(capability_key, object_signature)
target_number = _float_or_none(target_value)
if current_number is not None and target_number is not None:
tolerance = max(abs(current_number), abs(target_number), 1.0) * 1.0e-9
if abs(current_number - target_number) <= tolerance:
return {
"ok": False,
"reason": "target-already-current",
"message": "目标值与当前值相同,不需要修改。",
"current": current_number,
"target": target_number,
}
current_vector = _current_vector_for_capability(capability_key, object_signature)
target_vector = _parse_vector3(target_value)
if len(current_vector) == 3 and len(target_vector) == 3:
error = sum((current_vector[index] - target_vector[index]) ** 2 for index in range(3)) ** 0.5
scale = max(max(abs(item) for item in current_vector + target_vector), 1.0)
if error <= scale * 1.0e-9:
return {
"ok": False,
"reason": "target-already-current",
"message": "目标位置与当前位置相同,不需要修改。",
"current": current_vector,
"target": target_vector,
}
return {"ok": True, "reason": "ok"}
def _preflight_required_geometry(capability_key: str, signature: Mapping[str, object]) -> dict[str, object]:
if capability_key == "slot.depth":
if _positive_number(signature.get("depth")) is None:
return _missing_signature("object-signature-missing-depth", "SCDM 已识别槽深,但缓存里没有当前槽深,不能稳定执行。")
if not _has_vector3(signature.get("depthAxis") or signature.get("depthDirection")):
return _missing_signature("object-signature-missing-depth-axis", "SCDM 已识别槽深,但缓存里没有槽深方向,不能稳定执行。")
if not _has_face_locator_evidence(signature, ("depthFaceLocators", "bottomFaceLocators"), ("depthFaceOrdinals", "bottomFaceOrdinals"), ("globalDepthFaceOrdinals", "globalBottomFaceOrdinals")):
return _missing_signature("object-signature-missing-depth-face-locator", "SCDM 已识别槽深,但缓存里没有可推动的槽底面定位,不能稳定执行。")
elif capability_key == "boss.height":
if _positive_number(signature.get("height")) is None:
return _missing_signature("object-signature-missing-height", "SCDM 已识别凸台高度,但缓存里没有当前高度,不能稳定执行。")
if not _has_vector3(signature.get("axis")):
return _missing_signature("object-signature-missing-axis", "SCDM 已识别凸台高度,但缓存里没有高度方向,不能稳定执行。")
if not _has_face_locator_evidence(signature, ("heightFaceLocators", "topFaceLocators"), ("heightFaceOrdinals", "topFaceOrdinals"), ("globalHeightFaceOrdinals", "globalTopFaceOrdinals")):
return _missing_signature("object-signature-missing-height-face-locator", "SCDM 已识别凸台高度,但缓存里没有可推动的顶面定位,不能稳定执行。")
elif capability_key == "boss.diameter":
if _positive_number(signature.get("diameter"), _twice(signature.get("radius"))) is None:
return _missing_signature("object-signature-missing-diameter", "SCDM 已识别凸台直径,但缓存里没有当前直径,不能稳定执行。")
if not _has_face_locator_evidence(signature, ("diameterFaceLocators", "sideFaceLocators"), ("diameterFaceOrdinals", "sideFaceOrdinals"), ("globalDiameterFaceOrdinals", "globalSideFaceOrdinals")):
return _missing_signature("object-signature-missing-diameter-face-locator", "SCDM 已识别凸台直径,但缓存里没有可偏移的侧壁定位,不能稳定执行。")
elif capability_key == "round.radius":
if signature.get("isConstantRound") is not True:
return _missing_signature("object-signature-missing-constant-round-evidence", "SCDM 没有返回等半径圆角证据,不能稳定修改圆角半径。")
if _positive_number(signature.get("radius")) is None:
return _missing_signature("object-signature-missing-round-radius", "SCDM 已识别圆角,但缓存里没有当前半径,不能稳定执行。")
if not _has_face_locator_evidence(signature, ("scdmFaceLocators",), ("faceOrdinals",), ("globalFaceOrdinals",), allow_scalar_face=True):
return _missing_signature("object-signature-missing-round-face-locator", "SCDM 已识别圆角,但缓存里没有可定位的圆角面,不能稳定执行。")
elif capability_key == "chamfer.distance":
if signature.get("isEqualDistanceChamfer") is not True:
return _missing_signature("object-signature-missing-equal-distance-chamfer-evidence", "SCDM 没有返回等距倒角证据,不能稳定修改倒角距离。")
if _positive_number(signature.get("distance"), signature.get("offset")) is None:
return _missing_signature("object-signature-missing-chamfer-distance", "SCDM 已识别倒角,但缓存里没有当前距离,不能稳定执行。")
if not _has_face_locator_evidence(signature, ("scdmFaceLocators",), ("faceOrdinals",), ("globalFaceOrdinals",), allow_scalar_face=True):
return _missing_signature("object-signature-missing-chamfer-face-locator", "SCDM 已识别倒角,但缓存里没有可定位的倒角面,不能稳定执行。")
elif capability_key == "shell.thickness":
if _positive_number(signature.get("thickness")) is None:
return _missing_signature("object-signature-missing-shell-thickness", "SCDM 已识别壳体厚度,但缓存里没有当前厚度,不能稳定执行。")
if not _has_vector3(signature.get("thicknessAxis") or signature.get("axis")):
return _missing_signature("object-signature-missing-shell-thickness-axis", "SCDM 已识别壳体厚度,但缓存里没有厚度方向,不能稳定执行。")
if not _has_face_locator_evidence(signature, ("wallFaceLocators", "scdmFaceLocators"), ("wallFaceOrdinals", "faceOrdinals"), ("globalWallFaceOrdinals", "globalFaceOrdinals"), minimum=2):
return _missing_signature("object-signature-missing-wall-face-locator", "SCDM 已识别壳体厚度,但缓存里没有两侧墙面定位,不能稳定执行。")
elif capability_key == "pattern.instance_position":
if not _has_vector3(signature.get("center") or signature.get("instanceCenter")):
return _missing_signature("object-signature-missing-pattern-instance-center", "SCDM 已识别阵列实例,但缓存里没有当前实例位置,不能稳定执行。")
if not _has_pattern_instance_locator(signature):
return _missing_signature("object-signature-missing-pattern-instance-locator", "SCDM 已识别阵列实例,但缓存里没有可定位的 Face / Body / Component,不能稳定执行。")
return {"ok": True, "reason": "ok"}
def _missing_signature(reason: str, message: str) -> dict[str, object]:
return {"ok": False, "reason": reason, "message": message}
def _has_vector3(value: object) -> bool:
return len(_parse_vector3(value)) == 3
def _has_face_locator_evidence(
signature: Mapping[str, object],
locator_keys: tuple[str, ...],
ordinal_keys: tuple[str, ...],
global_ordinal_keys: tuple[str, ...],
*,
minimum: int = 1,
allow_scalar_face: bool = False,
) -> bool:
count = 0
for key in locator_keys:
value = signature.get(key)
if isinstance(value, (list, tuple)):
count += sum(1 for item in value if isinstance(item, Mapping))
for key in ordinal_keys + global_ordinal_keys:
value = signature.get(key)
if isinstance(value, (list, tuple)):
count += sum(1 for item in value if _int_or_none_value(item) is not None)
if count >= minimum:
return True
if allow_scalar_face and minimum <= 1:
return any(_int_or_none_value(signature.get(key)) is not None for key in ("faceOrdinal", "globalFaceOrdinal"))
return False
def _has_pattern_instance_locator(signature: Mapping[str, object]) -> bool:
if signature.get("componentLocators") or signature.get("bodyLocators") or signature.get("scdmFaceLocators"):
return True
if _has_face_locator_evidence(signature, (), ("faceOrdinals",), ("globalFaceOrdinals",), allow_scalar_face=True):
return True
instance_kind = str(signature.get("instanceKind") or "").strip().lower()
return instance_kind in {"body", "part", "component"} and _int_or_none_value(signature.get("bodyIndex")) is not None
def _positive_number_capabilities() -> set[str]:
return {
"hole.diameter",
"slot.width",
"slot.depth",
"boss.height",
"boss.diameter",
"round.radius",
"chamfer.distance",
"pattern.spacing",
"pattern.segment_spacing",
"shell.thickness",
}
def _current_number_for_capability(capability_key: str, signature: Mapping[str, object]) -> float | None:
if capability_key == "hole.diameter":
return _first_number(signature.get("diameter"), _twice(signature.get("radius")))
if capability_key == "face.offset":
return _first_number(signature.get("planeOffset"), signature.get("offset"), 0.0)
if capability_key == "slot.width":
return _first_number(signature.get("width"), signature.get("diameter"), _twice(signature.get("radius")))
if capability_key == "slot.depth":
return _first_number(signature.get("depth"))
if capability_key == "boss.height":
return _first_number(signature.get("height"))
if capability_key == "boss.diameter":
return _first_number(signature.get("diameter"), _twice(signature.get("radius")))
if capability_key == "round.radius":
return _first_number(signature.get("radius"))
if capability_key == "chamfer.distance":
return _first_number(signature.get("distance"), signature.get("offset"))
if capability_key == "pattern.spacing":
return _first_number(signature.get("spacing"), signature.get("pitch"))
if capability_key == "pattern.segment_spacing":
return _first_number(signature.get("segmentSpacing"))
if capability_key == "shell.thickness":
return _first_number(signature.get("thickness"))
return None
def _current_vector_for_capability(capability_key: str, signature: Mapping[str, object]) -> list[float]:
if capability_key in {"hole.position", "slot.position", "boss.position", "pattern.instance_position"}:
return _parse_vector3(signature.get("center") or signature.get("axisCenter") or signature.get("instanceCenter"))
return []
def _first_number(*values: object) -> float | None:
for value in values:
number = _float_or_none(value)
if number is not None:
return number
return None
def _positive_number(*values: object) -> float | None:
number = _first_number(*values)
return number if number is not None and number > 0 else None
def _twice(value: object) -> float | None:
number = _float_or_none(value)
return None if number is None else number * 2.0
def _float_or_none(value: object) -> float | None: def _float_or_none(value: object) -> float | None:
try: try:
return float(str(value).strip()) return float(str(value).strip())
@@ -935,6 +1147,18 @@ def _locate_depth_faces(signature):
raise Exception('object_signature_missing_depth_face_locator') raise Exception('object_signature_missing_depth_face_locator')
def _locate_wall_faces(signature):
faces = _locate_named_faces(
signature,
('wallFaceLocators', 'scdmFaceLocators'),
('wallFaceOrdinals', 'faceOrdinals'),
('globalWallFaceOrdinals', 'globalFaceOrdinals'),
)
if len(faces) >= 2:
return faces
raise Exception('object_signature_missing_wall_face_locator')
def _locate_pattern_instance_faces(instance, fallback_body_index): def _locate_pattern_instance_faces(instance, fallback_body_index):
signature = dict(instance) signature = dict(instance)
if signature.get('bodyIndex') is None and fallback_body_index is not None: if signature.get('bodyIndex') is None and fallback_body_index is not None:
@@ -1246,10 +1470,25 @@ def _translate_selection(selection, delta):
raise Exception('capability_not_implemented: Move.Translate failed; ' + '; '.join(result.get('errors') or [])) raise Exception('capability_not_implemented: Move.Translate failed; ' + '; '.join(result.get('errors') or []))
def _delete_selection(selection, fill_errors=None):
delete = _command_type('Delete')
if delete is None:
errors = list(fill_errors or [])
errors.append('Delete command not available')
raise Exception('capability_not_implemented: Fill/Delete command not available; ' + '; '.join(errors))
variants = ((selection,), (selection, None))
fallback = _call_variants('Delete.Execute', delete.Execute, variants)
if fallback.get('ok'):
return {'command': 'Delete.Execute', 'apiSignature': fallback.get('signature'), 'fillErrors': list(fill_errors or [])}
errors = list(fill_errors or [])
errors.extend(fallback.get('errors') or [])
raise Exception('capability_not_implemented: Delete.Execute failed; ' + '; '.join(errors))
def _fill_selection(selection): def _fill_selection(selection):
fill = _command_type('Fill') fill = _command_type('Fill')
if fill is None: fill_errors = []
raise Exception('capability_not_implemented: Fill command not available') if fill is not None:
options = _new_options('FillOptions') options = _new_options('FillOptions')
for attr, value in (('AutoExtendFillArea', True), ('PatchBlend', True), ('ZipLaminarEdges', True)): for attr, value in (('AutoExtendFillArea', True), ('PatchBlend', True), ('ZipLaminarEdges', True)):
try: try:
@@ -1274,13 +1513,10 @@ def _fill_selection(selection):
result = _call_variants('Fill.Execute', fill.Execute, variants) result = _call_variants('Fill.Execute', fill.Execute, variants)
if result.get('ok'): if result.get('ok'):
return {'command': 'Fill.Execute', 'apiSignature': result.get('signature')} return {'command': 'Fill.Execute', 'apiSignature': result.get('signature')}
delete = _command_type('Delete') fill_errors.extend(result.get('errors') or [])
if delete is not None: else:
fallback = _call_variants('Delete.Execute', delete.Execute, ((selection,),)) fill_errors.append('Fill command not available')
if fallback.get('ok'): return _delete_selection(selection, fill_errors)
return {'command': 'Delete.Execute', 'apiSignature': fallback.get('signature'), 'fillErrors': result.get('errors') or []}
result['errors'].extend(fallback.get('errors') or [])
raise Exception('capability_not_implemented: Fill.Execute failed; ' + '; '.join(result.get('errors') or []))
def _float_value(value): def _float_value(value):
@@ -1794,6 +2030,18 @@ def change_pattern_segment_spacing(job):
for item in located[segment_index + 1:] for item in located[segment_index + 1:]
) )
spacing_mode = 'segment_split' spacing_mode = 'segment_split'
elif moving_side in ('single_left', 'only_left'):
delta = [axis_unit[index] * -delta_distance for index in range(3)]
moves = [
{'items': located[segment_index]['items'], 'kind': located[segment_index]['kind'], 'delta': delta, 'itemCount': len(located[segment_index]['items'])}
]
spacing_mode = 'segment_single_left'
elif moving_side in ('single_right', 'only_right'):
delta = [axis_unit[index] * delta_distance for index in range(3)]
moves = [
{'items': located[segment_index + 1]['items'], 'kind': located[segment_index + 1]['kind'], 'delta': delta, 'itemCount': len(located[segment_index + 1]['items'])}
]
spacing_mode = 'segment_single_right'
else: else:
raise Exception('capability_not_implemented: unsupported pattern segment spacing motion semantics: ' + moving_side) raise Exception('capability_not_implemented: unsupported pattern segment spacing motion semantics: ' + moving_side)
if not moves: if not moves:
@@ -1833,6 +2081,37 @@ def change_pattern_segment_spacing(job):
} }
def move_pattern_instance(job):
target = _vector3(job['target']['value'])
signature = job['object'].get('geometrySignature') or {}
center = signature.get('center') or signature.get('instanceCenter') or []
if not isinstance(center, list) or len(center) != 3:
raise Exception('object_signature_missing_pattern_instance_center')
delta = [float(target[index]) - float(center[index]) for index in range(3)]
if _vector_length(delta) <= 1e-12:
return {'command': 'noop', 'delta': delta, 'reason': 'target already reached'}
body_index = _int_or_none(signature.get('bodyIndex'))
try:
located = _locate_pattern_instance_items(signature, body_index)
items = located.get('items') or []
if not items:
raise Exception('object_signature_missing_pattern_instance_locator')
if located.get('kind') == 'component':
if len(items) != 1:
raise Exception('component_pattern_instance_not_unique')
applied = _translate_component_occurrence(items[0], delta)
else:
applied = _translate_selection(_selection(items), delta)
applied['targetCenter'] = target
applied['previousCenter'] = center
applied['delta'] = delta
applied['targetKind'] = located.get('kind') or 'face'
applied['itemCount'] = len(items)
return applied
except Exception as exc:
raise Exception('capability_not_implemented: pattern.instance_position adapter failed; ' + str(exc))
def change_boss_height(job): def change_boss_height(job):
target = _float_value(job['target']['value']) target = _float_value(job['target']['value'])
if target <= 0: if target <= 0:
@@ -2038,6 +2317,71 @@ def change_chamfer_distance(job):
raise Exception('capability_not_implemented: chamfer.distance adapter failed; ' + str(exc)) raise Exception('capability_not_implemented: chamfer.distance adapter failed; ' + str(exc))
def change_shell_thickness(job):
target = _float_value(job['target']['value'])
if target <= 0:
raise Exception('target_value_illegal: shell thickness must be positive')
signature = job['object'].get('geometrySignature') or {}
current = None
try:
current = float(signature.get('thickness'))
except Exception:
current = None
if current is None or current <= 0:
raise Exception('object_signature_missing_shell_thickness')
axis = signature.get('thicknessAxis') or signature.get('axis') or []
if not isinstance(axis, list) or len(axis) != 3:
raise Exception('object_signature_missing_shell_thickness_axis')
axis_length = _vector_length([float(axis[0]), float(axis[1]), float(axis[2])])
if axis_length <= 1e-12:
raise Exception('object_signature_missing_shell_thickness_axis')
delta_distance = target - current
if abs(delta_distance) <= 1e-12:
return {'command': 'noop', 'targetThickness': target, 'reason': 'target already reached'}
faces = _locate_wall_faces(signature)
centers = signature.get('wallFaceCenters') or []
sign = 1.0
if isinstance(centers, list) and len(centers) >= 2:
left = centers[0]
right = centers[1]
if isinstance(left, list) and len(left) == 3 and isinstance(right, list) and len(right) == 3:
projection_delta = sum((float(right[index]) - float(left[index])) * float(axis[index]) for index in range(3))
if projection_delta < 0:
sign = -1.0
moving_side = str(signature.get('movingSide') or 'after').strip().lower()
axis_unit = [float(axis[index]) / axis_length for index in range(3)]
if moving_side in ('before', 'left'):
moving_faces = [faces[0]]
delta = [axis_unit[index] * -sign * delta_distance for index in range(3)]
elif moving_side in ('split', 'both', 'center'):
left_delta = [axis_unit[index] * -sign * delta_distance * 0.5 for index in range(3)]
right_delta = [axis_unit[index] * sign * delta_distance * 0.5 for index in range(3)]
left_applied = _translate_selection(_selection([faces[0]]), left_delta)
right_applied = _translate_selection(_selection([faces[-1]]), right_delta)
return {
'command': 'Move.Translate shell wall pair',
'targetThickness': target,
'previousThickness': current,
'thicknessDelta': delta_distance,
'movingSide': moving_side,
'wallFaceCount': len(faces),
'moves': [left_applied, right_applied],
}
else:
moving_faces = [faces[-1]]
delta = [axis_unit[index] * sign * delta_distance for index in range(3)]
try:
applied = _translate_selection(_selection(moving_faces), delta)
applied['targetThickness'] = target
applied['previousThickness'] = current
applied['thicknessDelta'] = delta_distance
applied['movingSide'] = moving_side
applied['wallFaceCount'] = len(faces)
return applied
except Exception as exc:
raise Exception('capability_not_implemented: shell.thickness adapter failed; ' + str(exc))
def pull_face_offset(job): def pull_face_offset(job):
target = _float_value(job['target']['value']) target = _float_value(job['target']['value'])
signature = job['object'].get('geometrySignature') or {} signature = job['object'].get('geometrySignature') or {}
@@ -2097,6 +2441,8 @@ def _apply_edit(job):
return change_pattern_spacing(job) return change_pattern_spacing(job)
if operation == 'change_pattern_segment_spacing': if operation == 'change_pattern_segment_spacing':
return change_pattern_segment_spacing(job) return change_pattern_segment_spacing(job)
if operation == 'move_pattern_instance':
return move_pattern_instance(job)
if operation == 'change_boss_height': if operation == 'change_boss_height':
return change_boss_height(job) return change_boss_height(job)
if operation == 'change_boss_diameter': if operation == 'change_boss_diameter':
@@ -2105,6 +2451,8 @@ def _apply_edit(job):
return change_round_radius(job) return change_round_radius(job)
if operation == 'change_chamfer_distance': if operation == 'change_chamfer_distance':
return change_chamfer_distance(job) return change_chamfer_distance(job)
if operation == 'change_shell_thickness':
return change_shell_thickness(job)
if operation == 'pull_face_offset': if operation == 'pull_face_offset':
return pull_face_offset(job) return pull_face_offset(job)
if operation == 'fill_feature': if operation == 'fill_feature':
+102 -4
View File
@@ -8,7 +8,7 @@ from .scdm_capabilities import capability_definition, planned_capability_keys, p
from .scdm_schema import SCDM_CACHE_SCHEMA_VERSION, payload_backend_version, payload_model_fingerprint, read_json, utc_now, write_json from .scdm_schema import SCDM_CACHE_SCHEMA_VERSION, payload_backend_version, payload_model_fingerprint, read_json, utc_now, write_json
SCDM_FEATURE_CACHE_REVISION = 4 SCDM_FEATURE_CACHE_REVISION = 8
def map_scdm_raw_features(raw_payload: Mapping[str, object]) -> dict[str, object]: def map_scdm_raw_features(raw_payload: Mapping[str, object]) -> dict[str, object]:
@@ -400,6 +400,8 @@ def _cylindrical_face_group_objects(raw_objects: list[object]) -> list[dict[str,
for raw_object in raw_objects: for raw_object in raw_objects:
if not isinstance(raw_object, Mapping): if not isinstance(raw_object, Mapping):
continue continue
if not _can_derive_cylindrical_hole_group(raw_object):
continue
geometry = _mapping(raw_object.get("geometry")) geometry = _mapping(raw_object.get("geometry"))
if _surface_key(geometry.get("surfaceType") or geometry.get("surface")) != "cylinder": if _surface_key(geometry.get("surfaceType") or geometry.get("surface")) != "cylinder":
continue continue
@@ -453,6 +455,56 @@ def _cylindrical_face_group_objects(raw_objects: list[object]) -> list[dict[str,
return result return result
def _can_derive_cylindrical_hole_group(raw_object: Mapping[str, object]) -> bool:
object_type = str(raw_object.get("objectType") or "").strip().lower()
if object_type in {
"round",
"fillet",
"chamfer",
"slot",
"obround_slot",
"rectangular_slot",
"boss",
"cylindrical_boss",
"rectangular_boss",
"shell",
"thin_wall",
}:
return False
if object_type not in {"", "face", "planar_face", "hole", "cylindrical_hole"}:
return False
geometry = _mapping(raw_object.get("geometry"))
if _mapping(geometry.get("roundInfo")) or _mapping(geometry.get("chamferInfo")) or _mapping(geometry.get("slotInfo")):
return object_type in {"hole", "cylindrical_hole"}
commands = tuple(_raw_command_tokens(raw_object.get("backendCommandCandidates")))
if any(token in command for command in commands for token in ("round", "fillet", "chamfer", "slot", "boss")):
return object_type in {"hole", "cylindrical_hole"}
return True
def _raw_command_tokens(value: object) -> list[str]:
if not isinstance(value, list):
return []
tokens: list[str] = []
for item in value:
if not isinstance(item, Mapping):
continue
if item.get("enabled") is False:
continue
tokens.append(
" ".join(
str(part or "")
for part in (
item.get("key"),
item.get("operation"),
item.get("command"),
item.get("type"),
)
).lower()
)
return tokens
def _derived_feature_objects(source_objects: list[object]) -> list[dict[str, object]]: def _derived_feature_objects(source_objects: list[object]) -> list[dict[str, object]]:
return [ return [
*_derived_linear_pattern_objects(source_objects), *_derived_linear_pattern_objects(source_objects),
@@ -952,7 +1004,7 @@ def _derived_thin_wall_objects(source_objects: list[object]) -> list[dict[str, o
{"operation": "change_shell_thickness", "enabled": True, "parameterFields": {"thickness": round(float(candidate["thickness"]), 6)}}, {"operation": "change_shell_thickness", "enabled": True, "parameterFields": {"thickness": round(float(candidate["thickness"]), 6)}},
], ],
"rawLimitations": [ "rawLimitations": [
"Derived from paired planar SCDM faces; shell.thickness remains non-productized until real SCDM edit samples pass.", "Derived from paired planar SCDM faces; shell.thickness is productized only when both wall faces and thickness axis are locatable.",
], ],
} }
) )
@@ -1153,6 +1205,8 @@ def attach_local_face_ids_to_scdm_cache(
item = dict(raw_object) item = dict(raw_object)
signature = dict(item.get("geometrySignature") if isinstance(item.get("geometrySignature"), Mapping) else {}) signature = dict(item.get("geometrySignature") if isinstance(item.get("geometrySignature"), Mapping) else {})
if not _int_list(signature.get("faceIds")): if not _int_list(signature.get("faceIds")):
# SCDM 和本软件的 Face 编号不是同一套 ID。优先用 ordinal locator 精确映射;
# locator 不完整时才退到几何签名打分,避免修改后 ID 变化把公式续接错。
ordinal_face_ids = _face_ids_from_signature_ordinals(signature, local_signatures) ordinal_face_ids = _face_ids_from_signature_ordinals(signature, local_signatures)
if ordinal_face_ids: if ordinal_face_ids:
signature["faceIds"] = ordinal_face_ids signature["faceIds"] = ordinal_face_ids
@@ -1195,6 +1249,8 @@ def attach_local_face_ids_to_scdm_cache(
support_face_ids = _pattern_support_face_ids(signature, local_signatures) support_face_ids = _pattern_support_face_ids(signature, local_signatures)
if support_face_ids: if support_face_ids:
signature["supportFaceIds"] = support_face_ids signature["supportFaceIds"] = support_face_ids
# 阵列间距不能只看实例中心距,还要知道这些实例是否仍落在支撑面上。
# supportPatternFit 会给 UI 和 preflight 提供最大安全间距。
support_fit = _pattern_support_fit(signature, local_signatures, support_face_ids, unit_scale or 1.0) support_fit = _pattern_support_fit(signature, local_signatures, support_face_ids, unit_scale or 1.0)
if support_fit: if support_fit:
signature["supportPatternFit"] = support_fit signature["supportPatternFit"] = support_fit
@@ -1420,6 +1476,8 @@ def _pattern_support_face_ids(
if distance > tolerance: if distance > tolerance:
continue continue
area = _number(local.get("area")) or _box_area_estimate(face_box) area = _number(local.get("area")) or _box_area_estimate(face_box)
# 支撑面候选要求和阵列包围盒投影重叠,并且沿面法向几乎贴合;
# 面积越大、贴合越近,越像承载该阵列的底面。
candidates.append((overlap * max(area, 1.0) - distance, int(face_id))) candidates.append((overlap * max(area, 1.0) - distance, int(face_id)))
candidates.sort(reverse=True) candidates.sort(reverse=True)
return [face_id for _score, face_id in candidates[:4]] return [face_id for _score, face_id in candidates[:4]]
@@ -1469,6 +1527,8 @@ def _pattern_support_fit(
max_spacing_local = (2.0 * min(left_capacity, right_capacity)) / (len(center_projections) - 1) max_spacing_local = (2.0 * min(left_capacity, right_capacity)) / (len(center_projections) - 1)
if max_spacing_local <= 0: if max_spacing_local <= 0:
continue continue
# 这里按“保持整列中心不变”等距重排”估算最大间距。
# 局部段间距会在 property spec 里按具体移动语义再计算范围。
support_candidates.append( support_candidates.append(
{ {
"faceId": int(face_id), "faceId": int(face_id),
@@ -1742,20 +1802,58 @@ def _current_value(fields: tuple[str, ...], raw_object: Mapping[str, object]) ->
return 1 return 1
if field == "geometry.radius*2": if field == "geometry.radius*2":
radius = _number(geometry.get("radius")) radius = _number(geometry.get("radius"))
if radius is not None: if radius is not None and radius > 0:
return radius * 2.0 return radius * 2.0
continue continue
if field.startswith("geometry."): if field.startswith("geometry."):
value = _path_value(geometry, field.split(".", 1)[1]) value = _path_value(geometry, field.split(".", 1)[1])
if value is not None: if value is not None:
if _invalid_dimension_current_value(field, value):
continue
return value return value
return None return None
def _invalid_dimension_current_value(field: str, value: object) -> bool:
if not field.startswith("geometry."):
return False
path = field.split(".", 1)[1]
if path in {"offset", "planeOffset"} or path.endswith(".offset"):
return False
dimension_suffixes = (
"radius",
"diameter",
"width",
"depth",
"height",
"distance",
"thickness",
"spacing",
"pitch",
)
if not path.endswith(dimension_suffixes):
return False
number = _number(value)
return number is not None and number <= 0
def _missing_geometry_reason(key: str, raw_object: Mapping[str, object]) -> str: def _missing_geometry_reason(key: str, raw_object: Mapping[str, object]) -> str:
if key not in {"slot.depth", "boss.height", "boss.diameter", "round.radius", "chamfer.distance", "pattern.spacing"}: if key not in {"slot.depth", "boss.height", "boss.diameter", "round.radius", "chamfer.distance", "pattern.spacing", "shell.thickness"}:
return "" return ""
signature = geometry_signature(raw_object) signature = geometry_signature(raw_object)
if key == "shell.thickness":
thickness = _rounded_number(signature.get("thickness"))
if thickness is None or thickness <= 0:
return "SCDM 已识别薄壁候选,但没有返回可用于编辑的当前壳体厚度。"
axis = _rounded_vector(signature.get("thicknessAxis") or signature.get("axis"))
if len(axis) != 3:
return "SCDM 已识别薄壁厚度,但没有返回稳定的厚度方向,暂不能修改。"
wall_locators = _face_locators(signature.get("wallFaceLocators") or signature.get("scdmFaceLocators"))
wall_ordinals = _int_list(signature.get("wallFaceOrdinals") or signature.get("faceOrdinals"))
wall_global_ordinals = _int_list(signature.get("globalWallFaceOrdinals") or signature.get("globalFaceOrdinals"))
if len(wall_locators) < 2 and len(wall_ordinals) < 2 and len(wall_global_ordinals) < 2:
return "SCDM 已识别薄壁厚度,但没有返回两侧墙面的定位信息,暂不能修改。"
return ""
if key == "pattern.spacing": if key == "pattern.spacing":
spacing = _rounded_number(_first_present(signature.get("spacing"), signature.get("pitch"))) spacing = _rounded_number(_first_present(signature.get("spacing"), signature.get("pitch")))
if spacing is None or spacing <= 0: if spacing is None or spacing <= 0:
+219 -21
View File
@@ -8,11 +8,13 @@ def property_specs_from_scdm_cache(
*, *,
selected_face_ids: Iterable[int] = (), selected_face_ids: Iterable[int] = (),
selected_edge_ids: Iterable[int] = (), selected_edge_ids: Iterable[int] = (),
selected_solid_ids: Iterable[int] = (),
execution_ready: bool | Iterable[str] = False, execution_ready: bool | Iterable[str] = False,
) -> list[dict[str, object]]: ) -> list[dict[str, object]]:
face_ids = {int(item) for item in selected_face_ids} face_ids = {int(item) for item in selected_face_ids}
edge_ids = {int(item) for item in selected_edge_ids} edge_ids = {int(item) for item in selected_edge_ids}
if not face_ids and not edge_ids: solid_ids = {int(item) for item in selected_solid_ids}
if not face_ids and not edge_ids and not solid_ids:
return [] return []
objects = cache.get("objects") objects = cache.get("objects")
if not isinstance(objects, list): if not isinstance(objects, list):
@@ -20,30 +22,56 @@ def property_specs_from_scdm_cache(
specs: list[dict[str, object]] = [] specs: list[dict[str, object]] = []
for item in objects: for item in objects:
if not isinstance(item, Mapping) or not _object_matches(item, face_ids=face_ids, edge_ids=edge_ids): if not isinstance(item, Mapping) or not _object_matches(
item,
face_ids=face_ids,
edge_ids=edge_ids,
solid_ids=solid_ids,
):
continue continue
capabilities = item.get("capabilities") capabilities = item.get("capabilities")
if not isinstance(capabilities, list): if not isinstance(capabilities, list):
continue continue
for capability in capabilities: for capability in capabilities:
if isinstance(capability, Mapping): if isinstance(capability, Mapping):
if str(capability.get("key") or "") == "pattern.segment_spacing": if str(capability.get("key") or "") in {"pattern.segment_spacing", "pattern.instance_position"}:
continue continue
spec = _capability_spec(item, capability, execution_ready=execution_ready) spec = _capability_spec(item, capability, execution_ready=execution_ready)
if spec is not None: if spec is not None:
specs.append(spec) specs.append(spec)
specs.extend(
_pattern_instance_position_specs(
item,
selected_face_ids=face_ids,
selected_solid_ids=solid_ids,
execution_ready=execution_ready,
)
)
specs.extend(_pattern_segment_spacing_specs(item, execution_ready=execution_ready)) specs.extend(_pattern_segment_spacing_specs(item, execution_ready=execution_ready))
return specs return specs
def _object_matches(raw_object: Mapping[str, object], *, face_ids: set[int], edge_ids: set[int]) -> bool: def _object_matches(
raw_object: Mapping[str, object],
*,
face_ids: set[int],
edge_ids: set[int],
solid_ids: set[int],
) -> bool:
signature = raw_object.get("geometrySignature") signature = raw_object.get("geometrySignature")
if not isinstance(signature, Mapping): if not isinstance(signature, Mapping):
return False return False
object_faces = set(_int_values(signature.get("faceIds"))) object_faces = set(_int_values(signature.get("faceIds")))
object_faces.update(_int_values(signature.get("supportFaceIds"))) object_faces.update(_int_values(signature.get("supportFaceIds")))
object_edges = set(_int_values(signature.get("edgeIds"))) object_edges = set(_int_values(signature.get("edgeIds")))
return bool((face_ids and object_faces & face_ids) or (edge_ids and object_edges & edge_ids)) if (face_ids and object_faces & face_ids) or (edge_ids and object_edges & edge_ids):
return True
if not solid_ids:
return False
object_type = str(raw_object.get("objectType") or "").strip().lower()
if object_type not in {"pattern", "linear_pattern"}:
return False
return bool(_pattern_local_solid_ids(signature) & solid_ids)
def _capability_spec( def _capability_spec(
@@ -197,6 +225,8 @@ def _pattern_segment_spacing_specs(
can_execute = bool(_capability_execution_ready("pattern.segment_spacing", execution_ready) and not str(raw_object.get("blockReason") or "").strip()) can_execute = bool(_capability_execution_ready("pattern.segment_spacing", execution_ready) and not str(raw_object.get("blockReason") or "").strip())
specs: list[dict[str, object]] = [] specs: list[dict[str, object]] = []
for segment_index in range(len(instances) - 1): for segment_index in range(len(instances) - 1):
# UI 上展示的是相邻实例之间的“段间距”,不是整列统一 spacing。
# 每一段都带自己的移动语义和安全范围,避免“第 1-2 间距”改成整列平移。
left = instances[segment_index] left = instances[segment_index]
right = instances[segment_index + 1] right = instances[segment_index + 1]
left_label = _segment_instance_label(left, segment_index + 1) left_label = _segment_instance_label(left, segment_index + 1)
@@ -268,6 +298,138 @@ def _pattern_segment_spacing_specs(
return specs return specs
def _pattern_instance_position_specs(
raw_object: Mapping[str, object],
*,
selected_face_ids: set[int],
selected_solid_ids: set[int],
execution_ready: bool | Iterable[str],
) -> list[dict[str, object]]:
if str(raw_object.get("objectType") or "").strip().lower() not in {"pattern", "linear_pattern"}:
return []
signature = raw_object.get("geometrySignature")
if not isinstance(signature, Mapping):
return []
unit_scale = _unit_scale(signature)
can_execute = bool(_capability_execution_ready("pattern.instance_position", execution_ready) and not str(raw_object.get("blockReason") or "").strip())
specs: list[dict[str, object]] = []
instances = _pattern_instances_in_original_order(signature)
for ordinal, instance in enumerate(instances, start=1):
if selected_face_ids and not (set(_int_values(instance.get("faceIds"))) & selected_face_ids):
continue
if selected_solid_ids and not (_instance_local_solid_ids(instance) & selected_solid_ids):
continue
center = _float_values(instance.get("center") or instance.get("instanceCenter"))
if len(center) != 3:
continue
label = _segment_instance_label({"source": instance}, ordinal)
current_display = [value / unit_scale for value in center] if unit_scale > 0 else list(center)
instance_signature = _pattern_instance_signature(signature, instance, unit_scale=unit_scale, label=label)
locatable = _pattern_instance_has_locator(instance_signature)
enabled = bool(can_execute and locatable)
disabled_tip = ""
if not can_execute:
disabled_tip = "SCDM 已识别该阵列实例,但当前修改执行器尚未开放。"
elif not locatable:
disabled_tip = "SCDM 已识别该阵列实例,但缓存里没有可定位的 Face / Body / Component,不能稳定移动。"
range_hint = f"移动阵列实例:只平移 {label},不自动保持整体阵列等距;需要保持间距时请使用“阵列间距”或“局部间距”。"
specs.append(
{
"key": f"scdm:pattern.instance_position:{ordinal - 1}",
"label": f"{label}位置",
"current_raw": current_display,
"scdm_current_raw": center,
"scdm_unit_scale": unit_scale,
"current_text": _format_value(current_display, value_type="vector3"),
"target_text": _format_value(current_display, value_type="vector3"),
"editable": True,
"enabled": enabled,
"status_text": "可修改" if enabled else "暂未接入",
"scope_text": "只移动该实例",
"action": "apply_scdm_property_edit",
"value_type": "vector3",
"enabled_tip": range_hint if enabled else "",
"disabled_tip": disabled_tip,
"range_hint": range_hint,
"min_value": None,
"min_exclusive": False,
"max_value": None,
"scdm_object_id": raw_object.get("objectId"),
"scdm_source_backend_id": raw_object.get("sourceBackendId"),
"scdm_capability_key": "pattern.instance_position",
"scdm_backend_operation": "move_pattern_instance",
"scdm_post_check": "target_pattern_instance_center",
"scdm_geometry_signature": instance_signature,
}
)
return specs
def _pattern_instances_in_original_order(signature: Mapping[str, object]) -> list[Mapping[str, object]]:
value = signature.get("patternInstances")
if not isinstance(value, (list, tuple)):
return []
return [item for item in value if isinstance(item, Mapping)]
def _pattern_instance_signature(
signature: Mapping[str, object],
instance: Mapping[str, object],
*,
unit_scale: float,
label: str,
) -> dict[str, object]:
result = dict(instance)
result["objectType"] = "pattern_instance"
result["displayLabel"] = label
result["patternObjectType"] = signature.get("objectType")
result["patternKind"] = signature.get("patternKind")
result["instanceKind"] = instance.get("instanceKind") or signature.get("instanceKind")
result["axis"] = signature.get("axis")
result["localUnitScale"] = unit_scale
center = _float_values(instance.get("center") or instance.get("instanceCenter"))
if len(center) == 3:
result["center"] = center
result["instanceCenter"] = center
return result
def _pattern_instance_has_locator(signature: Mapping[str, object]) -> bool:
if signature.get("componentLocators") or signature.get("bodyLocators") or signature.get("scdmFaceLocators"):
return True
if _int_values(signature.get("faceOrdinals")) or _int_values(signature.get("globalFaceOrdinals")):
return True
if _int_or_none(signature.get("faceOrdinal")) is not None or _int_or_none(signature.get("globalFaceOrdinal")) is not None:
return True
instance_kind = str(signature.get("instanceKind") or "").strip().lower()
return instance_kind in {"body", "part", "component"} and _int_or_none(signature.get("bodyIndex")) is not None
def _pattern_local_solid_ids(signature: Mapping[str, object]) -> set[int]:
ids = set(_int_values(signature.get("localSolidIds")))
local_solid = _int_or_none(signature.get("localSolidId"))
if local_solid is not None:
ids.add(local_solid)
ids.update(_int_values(signature.get("bodyIndices")))
body_index = _int_or_none(signature.get("bodyIndex"))
if body_index is not None:
ids.add(body_index)
for instance in _pattern_instances_in_original_order(signature):
ids.update(_instance_local_solid_ids(instance))
return ids
def _instance_local_solid_ids(instance: Mapping[str, object]) -> set[int]:
ids = set(_int_values(instance.get("localSolidIds")))
local_solid = _int_or_none(instance.get("localSolidId"))
if local_solid is not None:
ids.add(local_solid)
body_index = _int_or_none(instance.get("bodyIndex"))
if body_index is not None:
ids.add(body_index)
return ids
def _sorted_pattern_instances(signature: Mapping[str, object], axis: list[float]) -> list[dict[str, object]]: def _sorted_pattern_instances(signature: Mapping[str, object], axis: list[float]) -> list[dict[str, object]]:
value = signature.get("patternInstances") value = signature.get("patternInstances")
if not isinstance(value, (list, tuple)): if not isinstance(value, (list, tuple)):
@@ -287,6 +449,7 @@ def _sorted_pattern_instances(signature: Mapping[str, object], axis: list[float]
"projection": _point_projection(center, axis), "projection": _point_projection(center, axis),
} }
) )
# 用阵列轴投影排序,比原始 cache 顺序更接近用户看到的左到右/前到后顺序。
result.sort(key=lambda item: float(item["projection"])) result.sort(key=lambda item: float(item["projection"]))
return result return result
@@ -314,7 +477,7 @@ def _segment_max_spacing_display(
last_projection_display = last_projection / unit_scale if unit_scale > 0 else last_projection last_projection_display = last_projection / unit_scale if unit_scale > 0 else last_projection
backward_capacity = first_projection_display - projection_min - (member_span * 0.5) backward_capacity = first_projection_display - projection_min - (member_span * 0.5)
forward_capacity = projection_max - (member_span * 0.5) - last_projection_display forward_capacity = projection_max - (member_span * 0.5) - last_projection_display
if moving_side in {"before", "left"}: if moving_side in {"before", "left", "single_left", "only_left"}:
extra = backward_capacity extra = backward_capacity
elif moving_side in {"split", "both", "center"}: elif moving_side in {"split", "both", "center"}:
extra = 2.0 * min(backward_capacity, forward_capacity) extra = 2.0 * min(backward_capacity, forward_capacity)
@@ -337,6 +500,8 @@ def _segment_scope_modes(
can_execute: bool, can_execute: bool,
) -> dict[str, dict[str, object]]: ) -> dict[str, dict[str, object]]:
modes: dict[str, dict[str, object]] = {} modes: dict[str, dict[str, object]] = {}
# 同一个“间距”参数有多种建模意图:固定哪一侧、是否保持中心。
# 这些模式会直接进入 scdm_edit_job,不能只作为 UI 文案存在。
for key, label, moving_side, semantics, description in ( for key, label, moving_side, semantics, description in (
( (
"fix_left_move_right", "fix_left_move_right",
@@ -391,24 +556,57 @@ def _segment_scope_modes(
"max_value": max_display, "max_value": max_display,
"scdm_geometry_signature": mode_signature, "scdm_geometry_signature": mode_signature,
} }
modes["move_single_right"] = { for key, label, moving_side, semantics, moved_label, neighbor_warning in (
"label": "只移动后项(未开放)", (
"enabled": False, "move_single_left",
"disabled_tip": ( "只移动前项",
f"只移动后项(未开放):只移动 {right_label} 会同时改变它和右侧下一个成员的间距,容易破坏阵列规律;" "single_left",
"需要交互确认后再开放。" "move_only_left_instance",
left_label,
"会改变它与左侧相邻成员的距离",
), ),
"range_hint": f"只移动后项(未开放):该策略暂不执行。对象段:{segment_label}", (
"scdm_geometry_signature": _segment_signature( "move_single_right",
"只移动后项",
"single_right",
"move_only_right_instance",
right_label,
"会改变它与右侧相邻成员的距离",
),
):
max_display = _segment_max_spacing_display(
signature,
instances,
segment_index,
current_display,
unit_scale,
moving_side=moving_side,
)
mode_signature = _segment_signature(
signature, signature,
segment_index, segment_index,
current_backend, current_backend,
unit_scale, unit_scale,
left_label=left_label, left_label=left_label,
right_label=right_label, right_label=right_label,
moving_side="single_right", moving_side=moving_side,
motion_semantics="move_only_right_instance_blocked", motion_semantics=semantics,
), max_display=max_display,
)
range_hint = (
f"{label}:只平移 {moved_label},把 {left_label}-{right_label} 这段调到目标间距;"
f"{neighbor_warning},不用于保持整列等距。对象段:{segment_label}"
)
if max_display is not None and max_display > 0:
range_hint += f" 当前支撑面约允许该策略最大间距 {max_display:g}"
modes[key] = {
"label": label,
"enabled": can_execute,
"enabled_tip": range_hint if can_execute else "",
"disabled_tip": "" if can_execute else "SCDM 已识别该局部间距,但当前修改执行器尚未开放。",
"range_hint": range_hint,
"max_value": max_display,
"scdm_geometry_signature": mode_signature,
} }
return modes return modes
@@ -464,7 +662,7 @@ def _segment_instance_reference(item: Mapping[str, object], *, label: str = "")
def _segment_instance_label(item: Mapping[str, object], ordinal: int) -> str: def _segment_instance_label(item: Mapping[str, object], ordinal: int) -> str:
source = item.get("source") source = item.get("source")
if not isinstance(source, Mapping): if not isinstance(source, Mapping):
return f"成员{ordinal}" return f"阵列成员{ordinal}"
instance_kind = str(source.get("instanceKind") or "").strip().lower() instance_kind = str(source.get("instanceKind") or "").strip().lower()
if instance_kind in {"body", "part", "component"}: if instance_kind in {"body", "part", "component"}:
local_solid_ids = sorted(set(_int_values(source.get("localSolidIds") or [source.get("localSolidId")]))) local_solid_ids = sorted(set(_int_values(source.get("localSolidIds") or [source.get("localSolidId")])))
@@ -478,7 +676,7 @@ def _segment_instance_label(item: Mapping[str, object], ordinal: int) -> str:
return component_label return component_label
body_index = _int_or_none(source.get("bodyIndex")) body_index = _int_or_none(source.get("bodyIndex"))
if body_index is not None: if body_index is not None:
return f"零件{body_index}" return f"Solid{body_index}"
face_ids = sorted(set(_int_values(source.get("faceIds")))) face_ids = sorted(set(_int_values(source.get("faceIds"))))
if face_ids: if face_ids:
return f"Face{face_ids[0]}{'' if len(face_ids) > 1 else ''}" return f"Face{face_ids[0]}{'' if len(face_ids) > 1 else ''}"
@@ -487,11 +685,11 @@ def _segment_instance_label(item: Mapping[str, object], ordinal: int) -> str:
return component_label return component_label
body_index = _int_or_none(source.get("bodyIndex")) body_index = _int_or_none(source.get("bodyIndex"))
if body_index is not None: if body_index is not None:
return f"零件{body_index}" return f"Solid{body_index}"
source_id = str(source.get("sourceObjectId") or "").strip() source_id = str(source.get("sourceObjectId") or "").strip()
if source_id: if source_id:
return source_id return source_id
return f"成员{ordinal}" return f"阵列成员{ordinal}"
def _component_locator_label(value: object, *, include_index: bool = True) -> str: def _component_locator_label(value: object, *, include_index: bool = True) -> str:
+140 -7
View File
@@ -51,10 +51,13 @@ def validate_scdm_edit_result(
summary_check: dict[str, object] = {"ok": None, "reason": "not-run", "message": "SCDM summary check needs the old and new caches."} summary_check: dict[str, object] = {"ok": None, "reason": "not-run", "message": "SCDM summary check needs the old and new caches."}
topology_check: dict[str, object] = {"ok": None, "reason": "not-run", "message": "Object drift check needs the old and new SCDM caches."} topology_check: dict[str, object] = {"ok": None, "reason": "not-run", "message": "Object drift check needs the old and new SCDM caches."}
context_failure: dict[str, object] | None = None
if before_cache and after_cache: if before_cache and after_cache:
# B-Rep 有效只说明 STEP 能读回来;还要比较 SCDM cache,防止一次局部编辑
# 顺手让其它可识别对象消失、漂移或变得歧义。
summary_check = check_scdm_summary_delta(before_cache, after_cache, capability_key=capability_key) summary_check = check_scdm_summary_delta(before_cache, after_cache, capability_key=capability_key)
if summary_check.get("ok") is False: if summary_check.get("ok") is False:
return { context_failure = {
"ok": False, "ok": False,
"reason": str(summary_check.get("reason") or "summary-drift"), "reason": str(summary_check.get("reason") or "summary-drift"),
"message": str(summary_check.get("message") or "SCDM result changed the model summary too much."), "message": str(summary_check.get("message") or "SCDM result changed the model summary too much."),
@@ -70,7 +73,8 @@ def validate_scdm_edit_result(
capability_key=capability_key, capability_key=capability_key,
) )
if topology_check.get("ok") is not True: if topology_check.get("ok") is not True:
return { if context_failure is None:
context_failure = {
"ok": False, "ok": False,
"reason": str(topology_check.get("reason") or "unexpected-object-drift"), "reason": str(topology_check.get("reason") or "unexpected-object-drift"),
"message": str(topology_check.get("message") or "SCDM result changed unrelated recognized objects."), "message": str(topology_check.get("message") or "SCDM result changed unrelated recognized objects."),
@@ -91,7 +95,12 @@ def validate_scdm_edit_result(
} }
if capability_key == "pattern.segment_spacing": if capability_key == "pattern.segment_spacing":
check = _check_pattern_segment_spacing_edit_result(edit_result, expected_target, tolerance=tolerance) check = _check_pattern_segment_spacing_edit_result(
edit_result,
expected_target,
before_signature=before_signature,
tolerance=tolerance,
)
if check.get("ok") is not True: if check.get("ok") is not True:
return { return {
"ok": False, "ok": False,
@@ -103,6 +112,15 @@ def validate_scdm_edit_result(
"brep": brep, "brep": brep,
"editResult": dict(edit_result), "editResult": dict(edit_result),
} }
warning = _context_guard_warning_if_target_verified(
context_failure,
capability_key=capability_key,
target_check=check,
summary_check=summary_check,
brep=brep,
)
if context_failure is not None and warning is None:
return context_failure
return { return {
"ok": True, "ok": True,
"reason": "ok", "reason": "ok",
@@ -115,11 +133,14 @@ def validate_scdm_edit_result(
"topologyCheck": topology_check, "topologyCheck": topology_check,
"brep": brep, "brep": brep,
"editResult": dict(edit_result), "editResult": dict(edit_result),
"validationWarnings": [warning] if warning else [],
} }
matched: dict[str, object] | None = None matched: dict[str, object] | None = None
removal_check: dict[str, object] = {"ok": None, "reason": "not-run", "message": "Removal check is not needed for this capability."} removal_check: dict[str, object] = {"ok": None, "reason": "not-run", "message": "Removal check is not needed for this capability."}
if before_signature and after_cache: if before_signature and after_cache:
# 修改后的拓扑 ID 可能变化,不能按旧 Face ID 验证;这里用几何签名在新 cache
# 里找唯一对象,再用它做目标值回测和关系式 ID 续接。
match = match_scdm_object_by_signature(before_signature, after_cache, capability_key=capability_key) match = match_scdm_object_by_signature(before_signature, after_cache, capability_key=capability_key)
status = str(match.get("status") or "") status = str(match.get("status") or "")
if _is_removal_capability(capability_key): if _is_removal_capability(capability_key):
@@ -176,6 +197,16 @@ def validate_scdm_edit_result(
else: else:
check = {"ok": None, "reason": "not-run", "message": "Target check needs a matched object and an expected target."} check = {"ok": None, "reason": "not-run", "message": "Target check needs a matched object and an expected target."}
warning = _context_guard_warning_if_target_verified(
context_failure,
capability_key=capability_key,
target_check=check,
summary_check=summary_check,
brep=brep,
)
if context_failure is not None and warning is None:
return context_failure
return { return {
"ok": True, "ok": True,
"reason": "ok", "reason": "ok",
@@ -188,6 +219,62 @@ def validate_scdm_edit_result(
"topologyCheck": topology_check, "topologyCheck": topology_check,
"brep": brep, "brep": brep,
"editResult": dict(edit_result), "editResult": dict(edit_result),
"validationWarnings": [warning] if warning else [],
}
def _context_guard_warning_if_target_verified(
context_failure: Mapping[str, object] | None,
*,
capability_key: str,
target_check: Mapping[str, object],
summary_check: Mapping[str, object],
brep: Mapping[str, object],
) -> dict[str, object] | None:
# SCDM 重新导出 STEP 时有时会重新划分 body count,但目标对象和 B-Rep 都正确。
# 对已能目标回测的几何能力,将这种情况降级为警告,避免把成功修改误回滚。
if brep.get("ok") is not True:
return None
if target_check.get("ok") is not True:
return None
if str(summary_check.get("reason") or "") != "body-count-repartitioned":
return None
if not _is_target_verified_scdm_geometry_capability(capability_key):
return None
if not isinstance(context_failure, Mapping):
return {
"reason": "body-count-repartitioned",
"message": str(summary_check.get("message") or "SCDM changed body count while re-exporting the STEP result."),
"summaryCheck": dict(summary_check),
}
warning = {
"reason": str(context_failure.get("reason") or "context-guard-warning"),
"message": str(context_failure.get("message") or "SCDM cache context changed after edit."),
"summaryCheck": dict(summary_check),
}
topology_check = context_failure.get("topologyCheck")
if isinstance(topology_check, Mapping):
warning["topologyCheck"] = dict(topology_check)
return warning
def _is_target_verified_scdm_geometry_capability(capability_key: str) -> bool:
return capability_key in {
"face.offset",
"hole.diameter",
"hole.position",
"slot.width",
"slot.depth",
"slot.position",
"boss.height",
"boss.diameter",
"boss.position",
"round.radius",
"chamfer.distance",
"pattern.spacing",
"pattern.segment_spacing",
"pattern.instance_position",
"shell.thickness",
} }
@@ -453,6 +540,7 @@ def _check_pattern_segment_spacing_edit_result(
edit_result: Mapping[str, object], edit_result: Mapping[str, object],
expected_target: object, expected_target: object,
*, *,
before_signature: Mapping[str, object] | None,
tolerance: float, tolerance: float,
) -> dict[str, object]: ) -> dict[str, object]:
applied = edit_result.get("applied") applied = edit_result.get("applied")
@@ -469,11 +557,53 @@ def _check_pattern_segment_spacing_edit_result(
expected = _number(expected_target) expected = _number(expected_target)
check = _number_check(actual, expected, "pattern.segment_spacing", tolerance) check = _number_check(actual, expected, "pattern.segment_spacing", tolerance)
if check.get("ok") is True: if check.get("ok") is True:
spacing_mode = str(applied.get("spacingMode") or "").strip()
known_modes = {
"segment_after",
"segment_before",
"segment_split",
"segment_single_left",
"segment_single_right",
}
if spacing_mode not in known_modes:
return {
"ok": False,
"reason": "pattern-segment-spacing-mode-missing",
"message": "SCDM edit result did not report a known local spacing mode.",
"spacingMode": spacing_mode,
"knownModes": sorted(known_modes),
}
expected_modes = _expected_pattern_segment_spacing_modes(before_signature)
if expected_modes and spacing_mode not in expected_modes:
return {
"ok": False,
"reason": "pattern-segment-spacing-mode-mismatch",
"message": f"SCDM edit used {spacing_mode}, but the selected modeling intent expected one of {sorted(expected_modes)}.",
"spacingMode": spacing_mode,
"expectedModes": sorted(expected_modes),
}
check["segmentIndex"] = applied.get("segmentIndex") check["segmentIndex"] = applied.get("segmentIndex")
check["spacingMode"] = applied.get("spacingMode") check["spacingMode"] = spacing_mode
return check return check
def _expected_pattern_segment_spacing_modes(signature: Mapping[str, object] | None) -> set[str]:
if not isinstance(signature, Mapping):
return set()
moving_side = str(signature.get("movingSide") or "").strip().lower()
if moving_side in {"after", "right"}:
return {"segment_after"}
if moving_side in {"before", "left"}:
return {"segment_before"}
if moving_side in {"split", "both", "center"}:
return {"segment_split"}
if moving_side in {"single_left", "only_left"}:
return {"segment_single_left"}
if moving_side in {"single_right", "only_right"}:
return {"segment_single_right"}
return set()
def check_scdm_summary_delta( def check_scdm_summary_delta(
before_cache: Mapping[str, object], before_cache: Mapping[str, object],
after_cache: Mapping[str, object], after_cache: Mapping[str, object],
@@ -493,11 +623,14 @@ def check_scdm_summary_delta(
body_after = _int_or_none(after_summary.get("bodyCount")) body_after = _int_or_none(after_summary.get("bodyCount"))
if body_before is not None and body_after is not None and body_before != body_after: if body_before is not None and body_after is not None and body_before != body_after:
return { return {
"ok": False, "ok": None,
"reason": "summary-drift", "reason": "body-count-repartitioned",
"message": f"SCDM result changed body count unexpectedly: {body_before} -> {body_after}.", "message": f"SCDM result changed body count during STEP re-export: {body_before} -> {body_after}.",
"before": dict(before_summary), "before": dict(before_summary),
"after": dict(after_summary), "after": dict(after_summary),
"metric": "bodyCount",
"beforeValue": body_before,
"afterValue": body_after,
} }
for key, label in (("faceCount", "Face"), ("edgeCount", "Edge"), ("objectCount", "对象")): for key, label in (("faceCount", "Face"), ("edgeCount", "Edge"), ("objectCount", "对象")):
+5 -3
View File
@@ -52,11 +52,11 @@ def summarize_scdm_runtime(
reason = message or "未拿到 SCDM 识别结果" reason = message or "未拿到 SCDM 识别结果"
detail = f"识别未启用:{reason};当前使用本软件已有能力。" detail = f"识别未启用:{reason};当前使用本软件已有能力。"
elif state == "deferred": elif state == "deferred":
detail = message or "大模型已延后 SCDM 全量识别,优先保证查看、旋转和点选流畅。" detail = message or "已延后 SCDM 全量识别,优先保证导入显示、旋转和点选流畅。"
elif state == "stale": elif state == "stale":
detail = message or "缓存已失效,导入、编辑、撤销或重做后会后台重新识别。" detail = message or "缓存已失效,用户选择对象后会按需重新识别。"
else: else:
detail = "导入 STEP 后会尝试启动 SCDM 识别;找不到时使用 OCCT/Analysis Situs 兜底" detail = "导入 STEP 后先显示模型;用户选择对象后再按需启动 SCDM 识别"
tooltip_lines = [headline, detail] tooltip_lines = [headline, detail]
if path: if path:
@@ -83,6 +83,8 @@ def summarize_scdm_capability_progress(
execution_ready: bool | set[str] | list[str] | tuple[str, ...] = False, execution_ready: bool | set[str] | list[str] | tuple[str, ...] = False,
) -> dict[str, object]: ) -> dict[str, object]:
ready_keys = _execution_ready_keys(execution_ready) ready_keys = _execution_ready_keys(execution_ready)
# 这里把“识别到”“计划中”“几何 hint”和“可执行”分开统计。
# 客户界面只展示能稳定解释的进度,不能把 SCDM/raw hint 直接包装成可改参数。
detection_counts = _cache_capability_counts(feature_cache) detection_counts = _cache_capability_counts(feature_cache)
blocked_counts = _cache_blocked_capability_counts(feature_cache) blocked_counts = _cache_blocked_capability_counts(feature_cache)
planned_counts = _planned_capability_counts(feature_cache) planned_counts = _planned_capability_counts(feature_cache)
+217 -84
View File
@@ -133,13 +133,14 @@ class WindowCoreMixin:
self.vtk_widget.Start() self.vtk_widget.Start()
except Exception: except Exception:
pass pass
try: self._render_window_safely(force=True)
self.render_window.Render()
except Exception:
pass
def _is_ui_thread(self) -> bool: def _is_ui_thread(self) -> bool:
return QThread.currentThread() == self.thread() try:
owner_thread = self.thread()
except AttributeError:
return True
return QThread.currentThread() == owner_thread
def _invoke_on_ui_thread(self, callback) -> None: def _invoke_on_ui_thread(self, callback) -> None:
if self._is_ui_thread(): if self._is_ui_thread():
@@ -148,6 +149,21 @@ class WindowCoreMixin:
if hasattr(self, "ui_task_requested"): if hasattr(self, "ui_task_requested"):
self.ui_task_requested.emit(callback) self.ui_task_requested.emit(callback)
def _render_window_safely(self, *, force: bool = False) -> None:
render_window = getattr(self, "render_window", None)
if render_window is None:
return
if not self._is_ui_thread():
# VTK/Qt 渲染窗口只能在 UI 线程触碰;后台 worker 请求渲染时转成 queued UI 任务。
self._invoke_on_ui_thread(lambda force=force: self._render_window_safely(force=force))
return
if bool(getattr(self, "scene_rebuild_in_progress", False)) and not force:
return
try:
render_window.Render()
except Exception:
pass
def _reroute_to_ui_thread(self, callback) -> bool: def _reroute_to_ui_thread(self, callback) -> bool:
if self._is_ui_thread(): if self._is_ui_thread():
return False return False
@@ -193,6 +209,8 @@ class WindowCoreMixin:
editor = getattr(self, "relation_formula_input", None) editor = getattr(self, "relation_formula_input", None)
popup = self._relation_formula_popup_widget() popup = self._relation_formula_popup_widget()
if editor is not None and hasattr(editor, "backspace"): if editor is not None and hasattr(editor, "backspace"):
# QCompleter 的 popup 会抢走 Backspace/Tab 焦点;手动把按键转回输入框,
# 否则用户补全后继续编辑时会出现“失焦、删不动”的体验。
if popup is not None and hasattr(popup, "hide"): if popup is not None and hasattr(popup, "hide"):
popup.hide() popup.hide()
if hasattr(editor, "setFocus"): if hasattr(editor, "setFocus"):
@@ -483,9 +501,7 @@ class WindowCoreMixin:
return return
self._fps_idle_rendering_zero = True self._fps_idle_rendering_zero = True
try: try:
render_window.Render() self._render_window_safely(force=True)
except Exception:
pass
finally: finally:
self._fps_idle_rendering_zero = False self._fps_idle_rendering_zero = False
@@ -1005,10 +1021,7 @@ class WindowCoreMixin:
self.renderer.ResetCameraClippingRange() self.renderer.ResetCameraClippingRange()
except Exception: except Exception:
pass pass
try: self._render_window_safely()
self.render_window.Render()
except Exception:
pass
def _hover_suppressed_after_camera(self) -> bool: def _hover_suppressed_after_camera(self) -> bool:
ended_at = getattr(self, "last_camera_interaction_ended_at", None) ended_at = getattr(self, "last_camera_interaction_ended_at", None)
@@ -1180,6 +1193,7 @@ class WindowCoreMixin:
show_internal_edges: bool, show_internal_edges: bool,
status_prefix: str, status_prefix: str,
defer_edges: bool = True, defer_edges: bool = True,
reset_camera: bool = True,
) -> bool: ) -> bool:
self.statusBar().showMessage(f"{status_prefix} {new_path.name}...") self.statusBar().showMessage(f"{status_prefix} {new_path.name}...")
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
@@ -1198,7 +1212,24 @@ class WindowCoreMixin:
return False return False
finally: finally:
QApplication.restoreOverrideCursor() QApplication.restoreOverrideCursor()
self._apply_loaded_model_result(result, reset_camera=True) try:
self._apply_loaded_model_result(result, reset_camera=reset_camera)
except Exception as exc:
pending = getattr(self, "pending_scdm_edit_reload", None)
if isinstance(pending, dict) and hasattr(self, "_reject_scdm_result_after_reload"):
self.pending_scdm_edit_reload = None
self._reject_scdm_result_after_reload(
pending,
{
"ok": False,
"reason": "result-load-failed",
"message": f"SCDM 修改结果 STEP 已生成,但加载到当前场景失败:{exc}",
},
)
else:
QMessageBox.critical(self, "Load failed", str(exc))
self.statusBar().showMessage("STEP load failed; current model was left unchanged.")
return False
timing_text = _timing_summary(result.get("timings") if isinstance(result, dict) else None) timing_text = _timing_summary(result.get("timings") if isinstance(result, dict) else None)
suffix = f"{timing_text}" if timing_text else "" suffix = f"{timing_text}" if timing_text else ""
self.statusBar().showMessage(f"Loaded {self.step_path.name}{suffix}") self.statusBar().showMessage(f"Loaded {self.step_path.name}{suffix}")
@@ -1224,6 +1255,9 @@ class WindowCoreMixin:
if polydata is None: if polydata is None:
return None return None
copied = vtk.vtkPolyData() copied = vtk.vtkPolyData()
try:
copied.DeepCopy(polydata)
except Exception:
copied.ShallowCopy(polydata) copied.ShallowCopy(polydata)
return copied return copied
@@ -1239,9 +1273,11 @@ class WindowCoreMixin:
apply_started = time.perf_counter() apply_started = time.perf_counter()
new_path = Path(result["path"]) new_path = Path(result["path"])
stats = result["stats"] stats = result["stats"]
pending_scdm_reload = isinstance(getattr(self, "pending_scdm_edit_reload", None), dict)
self.model = result["model"] self.model = result["model"]
self.step_path = new_path self.step_path = new_path
self._invalidate_scdm_feature_cache("模型已重新加载,SCDM cache 已失效。") self._invalidate_scdm_feature_cache("模型已重新加载,SCDM cache 已失效。")
if not pending_scdm_reload:
self._clear_history() self._clear_history()
if hasattr(self, "measure_text"): if hasattr(self, "measure_text"):
self.clear_measurement() self.clear_measurement()
@@ -1250,6 +1286,9 @@ class WindowCoreMixin:
self.path_label.setToolTip(path_text) self.path_label.setToolTip(path_text)
self.path_label.setCursorPosition(0) self.path_label.setCursorPosition(0)
self._populate_part_tree() self._populate_part_tree()
if pending_scdm_reload:
self._reset_selection(clear_highlight=True, clear_info=False)
else:
self._reset_selection() self._reset_selection()
large_interaction_model = self._large_model_interaction_mode(stats=stats) large_interaction_model = self._large_model_interaction_mode(stats=stats)
self.hide_edges_during_camera_interaction = large_interaction_model self.hide_edges_during_camera_interaction = large_interaction_model
@@ -1307,14 +1346,11 @@ class WindowCoreMixin:
self.statusBar().showMessage( self.statusBar().showMessage(
f"Loaded {self.step_path.name}; 大模型已跳过全量边线补绘,旋转会更流畅,切到 Edge 选择时再按需生成。" f"Loaded {self.step_path.name}; 大模型已跳过全量边线补绘,旋转会更流畅,切到 Edge 选择时再按需生成。"
) )
pending_scdm_reload = isinstance(getattr(self, "pending_scdm_edit_reload", None), dict)
scdm_cache_restored = False if pending_scdm_reload else self._restore_scdm_feature_cache_from_disk() scdm_cache_restored = False if pending_scdm_reload else self._restore_scdm_feature_cache_from_disk()
if large_interaction_model and not pending_scdm_reload and not scdm_cache_restored: if pending_scdm_reload:
self._defer_large_model_recognition_preloads() QTimer.singleShot(120, lambda: self._start_scdm_probe_preload(force=True))
else: elif not scdm_cache_restored:
QTimer.singleShot(160, self._start_asitus_hole_recognition_preload) self._defer_post_import_recognition_preloads()
if pending_scdm_reload or not scdm_cache_restored:
QTimer.singleShot(240, lambda: self._start_scdm_probe_preload(force=pending_scdm_reload))
def _large_model_interaction_mode(self, stats: object | None = None) -> bool: def _large_model_interaction_mode(self, stats: object | None = None) -> bool:
if stats is not None: if stats is not None:
@@ -1343,6 +1379,36 @@ class WindowCoreMixin:
if hasattr(self, "_update_current_capability_panel"): if hasattr(self, "_update_current_capability_panel"):
self._update_current_capability_panel() self._update_current_capability_panel()
def _defer_post_import_recognition_preloads(self) -> None:
self.scdm_feature_cache_state = "deferred"
self.scdm_feature_cache_message = (
"模型已先完成显示;SCDM 可修改参数将在用户选择对象后按需识别,避免导入阶段与首次渲染抢占资源。"
)
if self.model is not None:
try:
self.model.fail_asitus_hole_region_load("导入后已延后 Analysis Situs 全量孔组识别。")
except Exception:
pass
if hasattr(self, "_update_current_capability_panel"):
self._update_current_capability_panel()
def _maybe_start_scdm_probe_for_selection(self) -> None:
if self.model is None or self.step_path is None:
return
if str(getattr(self, "scdm_feature_cache_state", "") or "") != "deferred":
return
thread = getattr(self, "scdm_thread", None)
if thread is not None and thread.isRunning():
return
self.scdm_feature_cache_message = (
"已选择对象,正在按需调用 SCDM 识别可修改参数;"
"识别完成前暂不把圆柱类本地兜底判断当作已确认孔/槽能力。"
)
if hasattr(self, "_update_current_capability_panel"):
self._update_current_capability_panel()
self.statusBar().showMessage("正在按需识别 SCDM 可修改参数,可继续查看模型。")
QTimer.singleShot(0, lambda: self._start_scdm_probe_preload(force=True))
def _invalidate_scdm_feature_cache(self, message: str = "") -> None: def _invalidate_scdm_feature_cache(self, message: str = "") -> None:
self.scdm_feature_cache = None self.scdm_feature_cache = None
self.scdm_feature_cache_state = "stale" self.scdm_feature_cache_state = "stale"
@@ -1356,7 +1422,30 @@ class WindowCoreMixin:
if hasattr(self, "_update_current_capability_panel"): if hasattr(self, "_update_current_capability_panel"):
self._update_current_capability_panel() self._update_current_capability_panel()
def _install_scdm_feature_cache(self, cache: dict[str, object], *, cache_path: str = "", message: str = "") -> None: def _scdm_feature_cache_valid_for_loaded_step(self, cache: object) -> bool:
if self.step_path is None or not isinstance(cache, dict):
return False
try:
fingerprint = file_fingerprint(Path(self.step_path))
except OSError:
return False
revision = _safe_int_or_none(cache.get("mapperRevision")) or 0
return str(cache.get("modelFingerprint") or "") == fingerprint and revision >= SCDM_FEATURE_CACHE_REVISION
def _install_scdm_feature_cache(self, cache: dict[str, object], *, cache_path: str = "", message: str = "") -> bool:
if not self._scdm_feature_cache_valid_for_loaded_step(cache):
self.scdm_feature_cache = None
self.scdm_feature_cache_state = "stale"
self.scdm_feature_cache_message = "本地 SCDM cache 与当前模型或当前 mapper 版本不匹配,已丢弃。"
self.scdm_feature_cache_path = ""
if self.model is not None:
try:
self.model.scdm_feature_cache = None
except Exception:
pass
if hasattr(self, "_update_current_capability_panel"):
self._update_current_capability_panel()
return False
self.scdm_feature_cache = dict(cache) self.scdm_feature_cache = dict(cache)
self.scdm_feature_cache_state = "ready" self.scdm_feature_cache_state = "ready"
self.scdm_feature_cache_message = message or "SCDM 可修改参数识别完成。" self.scdm_feature_cache_message = message or "SCDM 可修改参数识别完成。"
@@ -1370,6 +1459,7 @@ class WindowCoreMixin:
self._update_current_capability_panel() self._update_current_capability_panel()
if hasattr(self, "_refresh_property_editor"): if hasattr(self, "_refresh_property_editor"):
self._refresh_property_editor() self._refresh_property_editor()
return True
def _restore_scdm_feature_cache_from_disk(self) -> bool: def _restore_scdm_feature_cache_from_disk(self) -> bool:
if self.model is None or self.step_path is None: if self.model is None or self.step_path is None:
@@ -1382,7 +1472,6 @@ class WindowCoreMixin:
project_root = Path(__file__).resolve().parent.parent project_root = Path(__file__).resolve().parent.parent
work_dir = default_scdm_work_dir(step_path, project_root=project_root, fingerprint=fingerprint) work_dir = default_scdm_work_dir(step_path, project_root=project_root, fingerprint=fingerprint)
cache_path = work_dir / "scdm_feature_cache.json" cache_path = work_dir / "scdm_feature_cache.json"
raw_path = work_dir / "scdm_raw_features.json"
cache: dict[str, object] | None = None cache: dict[str, object] | None = None
if cache_path.is_file(): if cache_path.is_file():
@@ -1391,43 +1480,25 @@ class WindowCoreMixin:
revision = _safe_int_or_none(candidate.get("mapperRevision")) or 0 revision = _safe_int_or_none(candidate.get("mapperRevision")) or 0
if str(candidate.get("modelFingerprint") or "") == fingerprint and revision >= SCDM_FEATURE_CACHE_REVISION: if str(candidate.get("modelFingerprint") or "") == fingerprint and revision >= SCDM_FEATURE_CACHE_REVISION:
cache = candidate cache = candidate
except Exception: elif str(candidate.get("modelFingerprint") or "") == fingerprint:
cache = None self.scdm_feature_cache_message = "本地 SCDM cache 版本过旧,将在选择对象后按需重新识别。"
if cache is None and raw_path.is_file():
try:
raw = read_json(raw_path)
raw_model = raw.get("model")
raw_fingerprint = str(raw_model.get("fingerprint") or "") if isinstance(raw_model, dict) else ""
if raw_fingerprint == fingerprint:
cache = attach_local_face_ids_to_scdm_cache(
map_scdm_raw_features(raw),
self._scdm_local_face_signatures(),
)
write_json(cache_path, cache)
except Exception: except Exception:
cache = None cache = None
if cache is None: if cache is None:
return False return False
self._install_scdm_feature_cache( installed = self._install_scdm_feature_cache(
cache, cache,
cache_path=str(cache_path), cache_path=str(cache_path),
message="已从本地 SCDM 识别缓存恢复;模型变更后会重新识别。", message="已从本地 SCDM 识别缓存恢复;模型变更后会重新识别。",
) )
if not installed:
return False
self.statusBar().showMessage("已恢复本地 SCDM 识别缓存,参数表可直接使用。") self.statusBar().showMessage("已恢复本地 SCDM 识别缓存,参数表可直接使用。")
return True return True
def _current_scdm_feature_cache_matches_loaded_step(self) -> bool: def _current_scdm_feature_cache_matches_loaded_step(self) -> bool:
if self.step_path is None or not isinstance(getattr(self, "scdm_feature_cache", None), dict): return self._scdm_feature_cache_valid_for_loaded_step(getattr(self, "scdm_feature_cache", None))
return False
cache = getattr(self, "scdm_feature_cache", None)
try:
fingerprint = file_fingerprint(Path(self.step_path))
except OSError:
return False
revision = _safe_int_or_none(cache.get("mapperRevision")) or 0
return str(cache.get("modelFingerprint") or "") == fingerprint and revision >= SCDM_FEATURE_CACHE_REVISION
def _start_scdm_probe_preload(self, *, force: bool = False) -> None: def _start_scdm_probe_preload(self, *, force: bool = False) -> None:
if self.model is None or self.step_path is None: if self.model is None or self.step_path is None:
@@ -1441,11 +1512,14 @@ class WindowCoreMixin:
QTimer.singleShot(500, lambda: self._start_scdm_probe_preload(force=force)) QTimer.singleShot(500, lambda: self._start_scdm_probe_preload(force=force))
return return
step_path = Path(self.step_path) step_path = Path(self.step_path)
try:
local_face_signatures = [dict(item) for item in self._scdm_local_face_signatures()]
except Exception:
local_face_signatures = []
context = { context = {
"path": step_path, "path": step_path,
"model_id": id(self.model), "model_id": id(self.model),
} }
model = self.model
self.pending_scdm_context = dict(context) self.pending_scdm_context = dict(context)
self.scdm_feature_cache_state = "running" self.scdm_feature_cache_state = "running"
self.scdm_feature_cache_message = "正在识别 SCDM 可修改参数。" self.scdm_feature_cache_message = "正在识别 SCDM 可修改参数。"
@@ -1453,7 +1527,7 @@ class WindowCoreMixin:
if hasattr(self, "_update_current_capability_panel"): if hasattr(self, "_update_current_capability_panel"):
self._update_current_capability_panel() self._update_current_capability_panel()
def action(path=step_path, model=model): def action(path=step_path, face_signatures=tuple(local_face_signatures)):
probe = run_scdm_probe(path, project_root=Path(__file__).resolve().parent.parent, timeout_seconds=180.0) probe = run_scdm_probe(path, project_root=Path(__file__).resolve().parent.parent, timeout_seconds=180.0)
if not isinstance(probe, dict) or probe.get("ok") is not True: if not isinstance(probe, dict) or probe.get("ok") is not True:
return { return {
@@ -1465,13 +1539,6 @@ class WindowCoreMixin:
raw = probe.get("raw") raw = probe.get("raw")
if not isinstance(raw, dict): if not isinstance(raw, dict):
return {"ok": False, "reason": "missing-raw", "message": "SCDM probe did not return raw feature data.", "probe": probe} return {"ok": False, "reason": "missing-raw", "message": "SCDM probe did not return raw feature data.", "probe": probe}
face_signatures = []
builder = getattr(model, "scdm_local_face_signatures", None)
if callable(builder):
try:
face_signatures = [dict(item) for item in builder() if isinstance(item, dict)]
except Exception:
face_signatures = []
cache = attach_local_face_ids_to_scdm_cache( cache = attach_local_face_ids_to_scdm_cache(
map_scdm_raw_features(raw), map_scdm_raw_features(raw),
face_signatures, face_signatures,
@@ -1553,11 +1620,16 @@ class WindowCoreMixin:
return return
if isinstance(result.get("backend"), dict): if isinstance(result.get("backend"), dict):
self.scdm_backend_status = dict(result["backend"]) self.scdm_backend_status = dict(result["backend"])
self._install_scdm_feature_cache( installed = self._install_scdm_feature_cache(
dict(cache), dict(cache),
cache_path=str(result.get("cache_path") or ""), cache_path=str(result.get("cache_path") or ""),
message="SCDM 可修改参数识别完成。", message="SCDM 可修改参数识别完成。",
) )
if not installed:
self.statusBar().showMessage("SCDM cache 与当前模型不匹配,已丢弃并保持参数表不可用。")
if hasattr(self, "_finish_pending_scdm_edit_reload"):
self._finish_pending_scdm_edit_reload(cache_ready=False, message="SCDM cache 与当前模型不匹配")
return
objects = cache.get("objects") objects = cache.get("objects")
count = len(objects) if isinstance(objects, list) else 0 count = len(objects) if isinstance(objects, list) else 0
self.statusBar().showMessage(f"SCDM 可修改参数识别完成:{count} 个产品化对象。") self.statusBar().showMessage(f"SCDM 可修改参数识别完成:{count} 个产品化对象。")
@@ -1754,6 +1826,7 @@ class WindowCoreMixin:
self.statusBar().showMessage(f"Quick preview is available; display refinement failed: {message}") self.statusBar().showMessage(f"Quick preview is available; display refinement failed: {message}")
def _end_load_task(self) -> None: def _end_load_task(self) -> None:
self.scene_rebuild_in_progress = False
self.load_in_progress = False self.load_in_progress = False
self.pending_load_path = None self.pending_load_path = None
self._update_action_states() self._update_action_states()
@@ -2136,7 +2209,7 @@ class WindowCoreMixin:
QMessageBox.information(self, "未选择对象", "请先选择零件、Solid、Face、Edge 或特征。") QMessageBox.information(self, "未选择对象", "请先选择零件、Solid、Face、Edge 或特征。")
return return
self._fit_camera_to_bounds(bounds) self._fit_camera_to_bounds(bounds)
self.render_window.Render() self._render_window_safely()
self.statusBar().showMessage("已对准选中对象") self.statusBar().showMessage("已对准选中对象")
def _selected_focus_bounds(self) -> tuple[float, float, float, float, float, float] | None: def _selected_focus_bounds(self) -> tuple[float, float, float, float, float, float] | None:
@@ -2275,7 +2348,7 @@ class WindowCoreMixin:
self.renderer.AddActor(self.edge_actor) self.renderer.AddActor(self.edge_actor)
self.edge_overlay_polydata_cache.clear() self.edge_overlay_polydata_cache.clear()
if render: if render:
self.render_window.Render() self._render_window_safely()
@Slot() @Slot()
def _rebuild_deferred_edge_display(self) -> None: def _rebuild_deferred_edge_display(self) -> None:
@@ -2327,12 +2400,51 @@ class WindowCoreMixin:
if self.step_path is None or Path(result.get("path", "")) != Path(self.step_path): if self.step_path is None or Path(result.get("path", "")) != Path(self.step_path):
return return
edge_polydata = self._copy_polydata_for_ui_thread(result.get("edge_polydata")) edge_polydata = self._copy_polydata_for_ui_thread(result.get("edge_polydata"))
self._install_edge_polydata(edge_polydata, render=True) deferred_result = dict(result)
deferred_result["edge_polydata"] = edge_polydata
if self._defer_scene_actor_update_if_interacting(
lambda deferred_result=deferred_result: self._apply_deferred_edge_display_result(deferred_result)
):
return
self._apply_deferred_edge_display_result(deferred_result)
finally:
self._request_thread_quit(self.load_refine_thread)
def _apply_deferred_edge_display_result(self, result: dict[str, object]) -> None:
if not isinstance(result, dict):
return
if self.model is None or id(self.model) != result.get("model_id"):
return
if self.step_path is None or Path(result.get("path", "")) != Path(self.step_path):
return
if self._defer_scene_actor_update_if_interacting(
lambda result=result: self._apply_deferred_edge_display_result(result)
):
return
self._install_edge_polydata(result.get("edge_polydata"), render=True)
self.large_model_edge_overlay_skipped = False self.large_model_edge_overlay_skipped = False
elapsed = float(result.get("elapsed") or 0.0) elapsed = float(result.get("elapsed") or 0.0)
self.statusBar().showMessage(f"模型边线已补充,用时 {elapsed:.1f}s") self.statusBar().showMessage(f"模型边线已补充,用时 {elapsed:.1f}s")
finally:
self._request_thread_quit(self.load_refine_thread) def _defer_scene_actor_update_if_interacting(self, callback, *, delay_ms: int = 140) -> bool:
if not self._is_ui_thread():
self._invoke_on_ui_thread(callback)
return True
if not self._scene_actor_update_should_wait_for_camera():
return False
QTimer.singleShot(max(int(delay_ms), 1), callback)
return True
def _scene_actor_update_should_wait_for_camera(self) -> bool:
if bool(getattr(self, "camera_interaction_active", False)):
return True
if bool(getattr(self, "pointer_button_down", False)):
return True
ended_at = getattr(self, "last_camera_interaction_ended_at", None)
if ended_at is None:
return False
elapsed_ms = (datetime.now() - ended_at).total_seconds() * 1000.0
return elapsed_ms < 120.0
@Slot(str) @Slot(str)
def _fail_deferred_edge_display(self, message: str) -> None: def _fail_deferred_edge_display(self, message: str) -> None:
@@ -2433,6 +2545,7 @@ class WindowCoreMixin:
return polydata return polydata
def _rebuild_scene_from_polydata(self, model_polydata, edge_polydata, reset_camera: bool = False) -> None: def _rebuild_scene_from_polydata(self, model_polydata, edge_polydata, reset_camera: bool = False) -> None:
self.scene_rebuild_in_progress = True
self._clear_overlay_polydata_cache() self._clear_overlay_polydata_cache()
self.renderer.RemoveAllViewProps() self.renderer.RemoveAllViewProps()
self.renderer.SetBackground(*VIEW_BACKGROUND_COLOR) self.renderer.SetBackground(*VIEW_BACKGROUND_COLOR)
@@ -2486,7 +2599,8 @@ class WindowCoreMixin:
self.renderer.ResetCamera() self.renderer.ResetCamera()
self._ensure_step_coordinate_axes() self._ensure_step_coordinate_axes()
self.renderer.ResetCameraClippingRange() self.renderer.ResetCameraClippingRange()
self.render_window.Render() self.scene_rebuild_in_progress = False
self._render_window_safely(force=True)
def _on_mode_changed(self, mode: str) -> None: def _on_mode_changed(self, mode: str) -> None:
self._clear_hover(render=True) self._clear_hover(render=True)
@@ -2620,6 +2734,7 @@ class WindowCoreMixin:
getattr(self, "load_in_progress", False) getattr(self, "load_in_progress", False)
or getattr(self, "operation_in_progress", False) or getattr(self, "operation_in_progress", False)
or getattr(self, "scan_in_progress", False) or getattr(self, "scan_in_progress", False)
or getattr(self, "scene_rebuild_in_progress", False)
or getattr(self, "model", None) is None or getattr(self, "model", None) is None
): ):
return None return None
@@ -2659,14 +2774,14 @@ class WindowCoreMixin:
if press_signature is None or release_signature != press_signature: if press_signature is None or release_signature != press_signature:
self._cancel_hover_tracking(render=False) self._cancel_hover_tracking(render=False)
if hasattr(self, "render_window"): if hasattr(self, "render_window"):
self.render_window.Render() self._render_window_safely()
if self.selected_kind is not None: if self.selected_kind is not None:
self.statusBar().showMessage("左键按下和松开未命中同一对象,已保持当前选择") self.statusBar().showMessage("左键按下和松开未命中同一对象,已保持当前选择")
return return
if target is None: if target is None:
self._cancel_hover_tracking(render=False) self._cancel_hover_tracking(render=False)
if hasattr(self, "render_window"): if hasattr(self, "render_window"):
self.render_window.Render() self._render_window_safely()
if self.selected_kind is not None: if self.selected_kind is not None:
self.statusBar().showMessage("未命中对象,已保持当前选择") self.statusBar().showMessage("未命中对象,已保持当前选择")
else: else:
@@ -2815,6 +2930,7 @@ class WindowCoreMixin:
self.operation_in_progress self.operation_in_progress
or self.scan_in_progress or self.scan_in_progress
or self.load_in_progress or self.load_in_progress
or getattr(self, "scene_rebuild_in_progress", False)
or self.model is None or self.model is None
or self.model_actor is None or self.model_actor is None
or self._hover_suppressed_after_camera() or self._hover_suppressed_after_camera()
@@ -2828,6 +2944,7 @@ class WindowCoreMixin:
self.operation_in_progress self.operation_in_progress
or self.scan_in_progress or self.scan_in_progress
or self.load_in_progress or self.load_in_progress
or getattr(self, "scene_rebuild_in_progress", False)
or self.model is None or self.model is None
or self.model_actor is None or self.model_actor is None
): ):
@@ -2843,9 +2960,11 @@ class WindowCoreMixin:
if ( if (
getattr(self, "pointer_button_down", False) getattr(self, "pointer_button_down", False)
or getattr(self, "camera_interaction_active", False) or getattr(self, "camera_interaction_active", False)
or getattr(self, "scene_rebuild_in_progress", False)
or getattr(self, "large_model_hover_disabled", False) or getattr(self, "large_model_hover_disabled", False)
or self._hover_suppressed_after_camera() or self._hover_suppressed_after_camera()
): ):
# 鼠标拖动旋转和场景重建期间不做 hover/pick,避免“只是看模型却自动选面”。
return return
position = (int(x), int(y)) position = (int(x), int(y))
threshold = int(getattr(self, "hover_move_threshold_px", 0) or 0) threshold = int(getattr(self, "hover_move_threshold_px", 0) or 0)
@@ -2869,6 +2988,7 @@ class WindowCoreMixin:
self.operation_in_progress self.operation_in_progress
or self.scan_in_progress or self.scan_in_progress
or self.load_in_progress or self.load_in_progress
or getattr(self, "scene_rebuild_in_progress", False)
or self.model is None or self.model is None
or self.model_actor is None or self.model_actor is None
or self.pending_hover_position is None or self.pending_hover_position is None
@@ -2918,11 +3038,13 @@ class WindowCoreMixin:
return None return None
def _pick_actor_cell(self, actor, x: int, y: int) -> dict[str, object] | None: def _pick_actor_cell(self, actor, x: int, y: int) -> dict[str, object] | None:
if actor is None: if actor is None or bool(getattr(self, "scene_rebuild_in_progress", False)):
return None return None
try:
self.picker.InitializePickList() self.picker.InitializePickList()
self.picker.PickFromListOn() self.picker.PickFromListOn()
self.picker.AddPickList(actor) self.picker.AddPickList(actor)
# 只拾取指定 actor,避免坐标轴、高亮 overlay 或边线 actor 抢到 Face/Edge 选择。
picked = self.picker.Pick(int(x), int(y), 0, self.renderer) picked = self.picker.Pick(int(x), int(y), 0, self.renderer)
self.picker.PickFromListOff() self.picker.PickFromListOff()
if not picked or self.picker.GetCellId() < 0: if not picked or self.picker.GetCellId() < 0:
@@ -2931,6 +3053,12 @@ class WindowCoreMixin:
"cell_id": int(self.picker.GetCellId()), "cell_id": int(self.picker.GetCellId()),
"pick_position": _vector_tuple(self.picker.GetPickPosition()), "pick_position": _vector_tuple(self.picker.GetPickPosition()),
} }
except Exception:
try:
self.picker.PickFromListOff()
except Exception:
pass
return None
def _pick_face_cell(self, x: int, y: int) -> dict[str, object] | None: def _pick_face_cell(self, x: int, y: int) -> dict[str, object] | None:
hit = self._pick_actor_cell(self.model_actor, x, y) hit = self._pick_actor_cell(self.model_actor, x, y)
@@ -3263,6 +3391,7 @@ class WindowCoreMixin:
self._sync_id_picker("Part", part_id) self._sync_id_picker("Part", part_id)
self.set_info(self._with_pick_info(info, pick_position)) self.set_info(self._with_pick_info(info, pick_position))
self._update_action_states() self._update_action_states()
self._maybe_start_scdm_probe_for_selection()
kind_label = _part_tree_kind_label(str(info.get("kind", "part"))) kind_label = _part_tree_kind_label(str(info.get("kind", "part")))
self.statusBar().showMessage(self._selection_status(f"已选择{kind_label} {part_id}", pick_position)) self.statusBar().showMessage(self._selection_status(f"已选择{kind_label} {part_id}", pick_position))
@@ -3288,6 +3417,7 @@ class WindowCoreMixin:
self._sync_id_picker("Solid", solid_id) self._sync_id_picker("Solid", solid_id)
self.set_info(self._with_pick_info(info, pick_position)) self.set_info(self._with_pick_info(info, pick_position))
self._update_action_states() self._update_action_states()
self._maybe_start_scdm_probe_for_selection()
self.statusBar().showMessage(self._selection_status(f"已选择Solid {solid_id}", pick_position)) self.statusBar().showMessage(self._selection_status(f"已选择Solid {solid_id}", pick_position))
def select_face( def select_face(
@@ -3328,6 +3458,7 @@ class WindowCoreMixin:
self._sync_id_picker("Feature" if feature_mode else "Face", logical_id) self._sync_id_picker("Feature" if feature_mode else "Face", logical_id)
self.set_info(self._with_pick_info(input_info, pick_position)) self.set_info(self._with_pick_info(input_info, pick_position))
self._update_action_states() self._update_action_states()
self._maybe_start_scdm_probe_for_selection()
message = f"已选择Face {logical_id}" if not feature_mode else f"已选择特征来源 Face {logical_id}" message = f"已选择Face {logical_id}" if not feature_mode else f"已选择特征来源 Face {logical_id}"
self.statusBar().showMessage(self._selection_status(message, pick_position)) self.statusBar().showMessage(self._selection_status(message, pick_position))
@@ -3383,6 +3514,7 @@ class WindowCoreMixin:
if hasattr(self, "_feature_display_label"): if hasattr(self, "_feature_display_label"):
feature_type = self._feature_display_label(feature_type) feature_type = self._feature_display_label(feature_type)
self._update_action_states() self._update_action_states()
self._maybe_start_scdm_probe_for_selection()
self.statusBar().showMessage(self._selection_status(f"已选择 {feature_type},来源 Face {logical_id}", pick_position)) self.statusBar().showMessage(self._selection_status(f"已选择 {feature_type},来源 Face {logical_id}", pick_position))
def _sync_cylindrical_edit_inputs(self, info: dict[str, object]) -> None: def _sync_cylindrical_edit_inputs(self, info: dict[str, object]) -> None:
@@ -3559,6 +3691,7 @@ class WindowCoreMixin:
self._sync_id_picker("Edge", edge_id) self._sync_id_picker("Edge", edge_id)
self.set_info(self._with_pick_info(info, pick_position)) self.set_info(self._with_pick_info(info, pick_position))
self._update_action_states() self._update_action_states()
self._maybe_start_scdm_probe_for_selection()
self.statusBar().showMessage(self._selection_status(f"已选择Edge {edge_id}", pick_position)) self.statusBar().showMessage(self._selection_status(f"已选择Edge {edge_id}", pick_position))
def _highlight_faces(self, face_ids=None, part_ids=None) -> None: def _highlight_faces(self, face_ids=None, part_ids=None) -> None:
@@ -3590,7 +3723,7 @@ class WindowCoreMixin:
self.highlight_actor = actor self.highlight_actor = actor
self.highlight_signature = signature self.highlight_signature = signature
self.renderer.AddActor(actor) self.renderer.AddActor(actor)
self.render_window.Render() self._render_window_safely()
def _highlight_edge(self, edge_id: int) -> None: def _highlight_edge(self, edge_id: int) -> None:
if self.model is None: if self.model is None:
@@ -3614,7 +3747,7 @@ class WindowCoreMixin:
self.edge_highlight_actor = actor self.edge_highlight_actor = actor
self.highlight_signature = signature self.highlight_signature = signature
self.renderer.AddActor(actor) self.renderer.AddActor(actor)
self.render_window.Render() self._render_window_safely()
def _hover_signature_for_target(self, target: dict[str, object] | None) -> tuple[str, int] | None: def _hover_signature_for_target(self, target: dict[str, object] | None) -> tuple[str, int] | None:
if self.model is None or target is None: if self.model is None or target is None:
@@ -3633,7 +3766,7 @@ class WindowCoreMixin:
self._clear_hover(render=False) self._clear_hover(render=False)
if target is None: if target is None:
self.render_window.Render() self._render_window_safely()
return return
kind = str(target["kind"]) kind = str(target["kind"])
@@ -3652,7 +3785,7 @@ class WindowCoreMixin:
face_ids = self._selection_same_domain_face_ids(target_id) or [target_id] face_ids = self._selection_same_domain_face_ids(target_id) or [target_id]
self._highlight_hover_faces(face_ids=face_ids) self._highlight_hover_faces(face_ids=face_ids)
self.hover_signature = signature self.hover_signature = signature
self.render_window.Render() self._render_window_safely()
def _highlight_hover_faces(self, face_ids=None, part_ids=None) -> None: def _highlight_hover_faces(self, face_ids=None, part_ids=None) -> None:
if self.model is None: if self.model is None:
@@ -3729,7 +3862,7 @@ class WindowCoreMixin:
removed = True removed = True
self.hover_signature = None self.hover_signature = None
if render and removed and hasattr(self, "render_window"): if render and removed and hasattr(self, "render_window"):
self.render_window.Render() self._render_window_safely()
def _clear_highlight(self) -> None: def _clear_highlight(self) -> None:
if self.highlight_actor is not None: if self.highlight_actor is not None:
@@ -3754,7 +3887,7 @@ class WindowCoreMixin:
self.edit_preview_actors = [] self.edit_preview_actors = []
self.edit_preview_actor = None self.edit_preview_actor = None
if render and hasattr(self, "render_window"): if render and hasattr(self, "render_window"):
self.render_window.Render() self._render_window_safely()
def _add_edit_preview_actor( def _add_edit_preview_actor(
self, self,
@@ -3806,7 +3939,7 @@ class WindowCoreMixin:
) )
self._add_edit_preview_actor(polydata, color, opacity=0.38, position=position) self._add_edit_preview_actor(polydata, color, opacity=0.38, position=position)
self._start_edit_preview_pulse() self._start_edit_preview_pulse()
self.render_window.Render() self._render_window_safely()
def _show_shell_thickness_preview( def _show_shell_thickness_preview(
self, self,
@@ -3840,7 +3973,7 @@ class WindowCoreMixin:
) )
self._add_edit_preview_actor(polydata, (0.0, 0.72, 0.78), opacity=0.34, position=movement) self._add_edit_preview_actor(polydata, (0.0, 0.72, 0.78), opacity=0.34, position=movement)
self._start_edit_preview_pulse() self._start_edit_preview_pulse()
self.render_window.Render() self._render_window_safely()
def _show_cylinder_resize_preview(self, face_id: int, diameter: float) -> None: def _show_cylinder_resize_preview(self, face_id: int, diameter: float) -> None:
if self.model is None: if self.model is None:
@@ -3857,7 +3990,7 @@ class WindowCoreMixin:
opacity = 0.28 if role == "fill" else 0.32 opacity = 0.28 if role == "fill" else 0.32
self._add_edit_preview_actor(preview["polydata"], color, opacity=opacity) self._add_edit_preview_actor(preview["polydata"], color, opacity=opacity)
self._start_edit_preview_pulse() self._start_edit_preview_pulse()
self.render_window.Render() self._render_window_safely()
def _show_cylinder_boss_resize_preview(self, face_id: int, diameter: float) -> None: def _show_cylinder_boss_resize_preview(self, face_id: int, diameter: float) -> None:
if self.model is None: if self.model is None:
@@ -3874,7 +4007,7 @@ class WindowCoreMixin:
opacity = 0.3 if role == "fill" else 0.34 opacity = 0.3 if role == "fill" else 0.34
self._add_edit_preview_actor(preview["polydata"], color, opacity=opacity) self._add_edit_preview_actor(preview["polydata"], color, opacity=opacity)
self._start_edit_preview_pulse() self._start_edit_preview_pulse()
self.render_window.Render() self._render_window_safely()
def _show_cylinder_boss_height_preview(self, face_id: int, target_height: float) -> None: def _show_cylinder_boss_height_preview(self, face_id: int, target_height: float) -> None:
if self.model is None: if self.model is None:
@@ -3887,7 +4020,7 @@ class WindowCoreMixin:
return return
self._add_edit_preview_actor(polydata, (0.0, 0.72, 0.78), opacity=0.34) self._add_edit_preview_actor(polydata, (0.0, 0.72, 0.78), opacity=0.34)
self._start_edit_preview_pulse() self._start_edit_preview_pulse()
self.render_window.Render() self._render_window_safely()
def _show_cylinder_suppress_preview(self, face_id: int) -> None: def _show_cylinder_suppress_preview(self, face_id: int) -> None:
if self.model is None: if self.model is None:
@@ -3901,7 +4034,7 @@ class WindowCoreMixin:
for preview in previews: for preview in previews:
self._add_edit_preview_actor(preview["polydata"], (0.0, 0.86, 0.34), opacity=0.3) self._add_edit_preview_actor(preview["polydata"], (0.0, 0.86, 0.34), opacity=0.3)
self._start_edit_preview_pulse() self._start_edit_preview_pulse()
self.render_window.Render() self._render_window_safely()
def _show_cylinder_depth_preview( def _show_cylinder_depth_preview(
self, self,
@@ -3927,7 +4060,7 @@ class WindowCoreMixin:
opacity = 0.3 if role == "fill" else 0.34 opacity = 0.3 if role == "fill" else 0.34
self._add_edit_preview_actor(preview["polydata"], color, opacity=opacity) self._add_edit_preview_actor(preview["polydata"], color, opacity=opacity)
self._start_edit_preview_pulse() self._start_edit_preview_pulse()
self.render_window.Render() self._render_window_safely()
def _show_existing_fillet_resize_preview(self, face_id: int, target_radius: float) -> None: def _show_existing_fillet_resize_preview(self, face_id: int, target_radius: float) -> None:
if self.model is None: if self.model is None:
@@ -3941,7 +4074,7 @@ class WindowCoreMixin:
for preview in previews: for preview in previews:
self._add_edit_preview_actor(preview["polydata"], (0.35, 0.45, 1.0), opacity=0.36) self._add_edit_preview_actor(preview["polydata"], (0.35, 0.45, 1.0), opacity=0.36)
self._start_edit_preview_pulse() self._start_edit_preview_pulse()
self.render_window.Render() self._render_window_safely()
def _show_edge_fillet_preview(self, edge_id: int, radius: float) -> None: def _show_edge_fillet_preview(self, edge_id: int, radius: float) -> None:
if self.model is None: if self.model is None:
@@ -3982,7 +4115,7 @@ class WindowCoreMixin:
else: else:
self._add_edit_preview_actor(polydata, (0.0, 0.72, 0.78), opacity=0.34) self._add_edit_preview_actor(polydata, (0.0, 0.72, 0.78), opacity=0.34)
self._start_edit_preview_pulse() self._start_edit_preview_pulse()
self.render_window.Render() self._render_window_safely()
def _show_edge_tube_preview( def _show_edge_tube_preview(
self, self,
@@ -4018,7 +4151,7 @@ class WindowCoreMixin:
tube.Update() tube.Update()
self._add_edit_preview_actor(tube.GetOutput(), color, opacity=0.34) self._add_edit_preview_actor(tube.GetOutput(), color, opacity=0.34)
self._start_edit_preview_pulse() self._start_edit_preview_pulse()
self.render_window.Render() self._render_window_safely()
def _start_edit_preview_pulse(self) -> None: def _start_edit_preview_pulse(self) -> None:
if self.edit_preview_timer is None: if self.edit_preview_timer is None:
@@ -4036,7 +4169,7 @@ class WindowCoreMixin:
opacity = self.edit_preview_base_opacity * (0.75 + 0.25 * (math.sin(self.edit_preview_phase) + 1.0) / 2.0) opacity = self.edit_preview_base_opacity * (0.75 + 0.25 * (math.sin(self.edit_preview_phase) + 1.0) / 2.0)
for actor in self.edit_preview_actors: for actor in self.edit_preview_actors:
actor.GetProperty().SetOpacity(opacity) actor.GetProperty().SetOpacity(opacity)
self.render_window.Render() self._render_window_safely()
def clear_diff_preview(self, render: bool = True) -> None: def clear_diff_preview(self, render: bool = True) -> None:
if not hasattr(self, "renderer"): if not hasattr(self, "renderer"):
@@ -4046,7 +4179,7 @@ class WindowCoreMixin:
self.renderer.RemoveViewProp(actor) self.renderer.RemoveViewProp(actor)
self.diff_actors.clear() self.diff_actors.clear()
if render and hasattr(self, "render_window"): if render and hasattr(self, "render_window"):
self.render_window.Render() self._render_window_safely()
self.statusBar().showMessage("已清除差异预览") self.statusBar().showMessage("已清除差异预览")
if hasattr(self, "mode_combo"): if hasattr(self, "mode_combo"):
self._update_action_states() self._update_action_states()
@@ -4070,7 +4203,7 @@ class WindowCoreMixin:
self.diff_actors.append(scalar_bar) self.diff_actors.append(scalar_bar)
for actor in self.diff_actors: for actor in self.diff_actors:
self.renderer.AddViewProp(actor) self.renderer.AddViewProp(actor)
self.render_window.Render() self._render_window_safely()
self._update_action_states() self._update_action_states()
if heatmap_stats: if heatmap_stats:
record.diff_stats = heatmap_stats record.diff_stats = heatmap_stats
+429 -17
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
import json
import math import math
from pathlib import Path from pathlib import Path
import re import re
@@ -482,7 +483,7 @@ class WindowStateMixin:
self.renderer.RemoveActor(self.pick_marker_actor) self.renderer.RemoveActor(self.pick_marker_actor)
self.pick_marker_actor = None self.pick_marker_actor = None
if getattr(self, "render_window", None) is not None: if getattr(self, "render_window", None) is not None:
self.render_window.Render() self._render_window_safely()
def _with_pick_info( def _with_pick_info(
self, self,
@@ -1911,7 +1912,7 @@ class WindowStateMixin:
headline = "当前支持:常见 Face、孔、槽、凸台、圆角/倒角、壳体、Edge" headline = "当前支持:常见 Face、孔、槽、凸台、圆角/倒角、壳体、Edge"
detail = "" detail = ""
tooltip = ( tooltip = (
"能改:Face 偏移,孔径/位置,槽位置,凸台位置/尺寸,部分槽宽/槽深,简单圆角/倒角,Edge 长度等。\n" "能改:Face 偏移,孔径/位置,证据完整的简单槽/长圆槽,凸台位置/尺寸,简单圆角/倒角,Edge 长度等。\n"
"能识别:基础拓扑、曲面类型、一级关系、孔组、槽、凸台、圆角/倒角候选。\n" "能识别:基础拓扑、曲面类型、一级关系、孔组、槽、凸台、圆角/倒角候选。\n"
"暂不能:原 CAD 历史树、复杂装配约束、二级/三级拓扑自动传播。" "暂不能:原 CAD 历史树、复杂装配约束、二级/三级拓扑自动传播。"
) )
@@ -2064,7 +2065,7 @@ class WindowStateMixin:
"## 已能修改", "## 已能修改",
"- **Face**:偏移/推拉,部分稳定矩形平面的长度、宽度,局部壳体厚度。", "- **Face**:偏移/推拉,部分稳定矩形平面的长度、宽度,局部壳体厚度。",
"- **孔**:直径、半径、位置、盲孔深度、封堵;拆成多片的圆柱孔会按同一孔组处理。", "- **孔**:直径、半径、位置、盲孔深度、封堵;拆成多片的圆柱孔会按同一孔组处理。",
"- **槽**:简单槽/长圆槽位置、槽宽、槽深、弧长、总长、中心距。", "- **槽**证据完整的简单槽/长圆槽可改位置、槽宽、槽深、弧长、总长、中心距;中置信度“圆柱孔/槽候选”只表示已识别到槽可能性,不等于已经可改",
"- **凸台**:圆柱凸台位置、直径、高度;部分矩形凸台/口袋的长度、宽度、高度或深度。", "- **凸台**:圆柱凸台位置、直径、高度;部分矩形凸台/口袋的长度、宽度、高度或深度。",
"- **圆角/倒角**:简单已有圆角半径、已有等距倒角,直线 Edge 新增圆角/倒角。", "- **圆角/倒角**:简单已有圆角半径、已有等距倒角,直线 Edge 新增圆角/倒角。",
"- **Edge**:直线 Edge 长度/端点,圆或椭圆 Edge 的半径类参数。", "- **Edge**:直线 Edge 长度/端点,圆或椭圆 Edge 的半径类参数。",
@@ -2081,6 +2082,7 @@ class WindowStateMixin:
"- 任意复杂自由曲面的直接变形。", "- 任意复杂自由曲面的直接变形。",
"- 二级/三级拓扑自动传播。", "- 二级/三级拓扑自动传播。",
"- 阵列间距联动、跨特征强约束、复杂公式组求解。", "- 阵列间距联动、跨特征强约束、复杂公式组求解。",
"- 只有槽候选、但缺少槽宽/槽深/槽底面/方向/成对端面等证据的复杂槽或多槽组。",
"- 复杂圆角链/倒角链批量重建。", "- 复杂圆角链/倒角链批量重建。",
"- 无法通过 B-Rep 校验或目标回测的修改会被阻止并回滚。", "- 无法通过 B-Rep 校验或目标回测的修改会被阻止并回滚。",
"", "",
@@ -2119,6 +2121,21 @@ class WindowStateMixin:
if selection_status: if selection_status:
info["scdm_selection_status"] = selection_status info["scdm_selection_status"] = selection_status
scdm_specs = self._scdm_property_specs_for_selection() if hasattr(self, "_scdm_property_specs_for_selection") else [] scdm_specs = self._scdm_property_specs_for_selection() if hasattr(self, "_scdm_property_specs_for_selection") else []
filter_message = ""
if scdm_specs and hasattr(self, "_filter_scdm_specs_for_local_recognition"):
try:
action_info = self._selected_action_info() if hasattr(self, "_selected_action_info") else info
except Exception:
action_info = info
try:
scdm_specs, filter_message = self._filter_scdm_specs_for_local_recognition(
list(scdm_specs),
dict(action_info or {}),
)
except Exception:
filter_message = ""
if filter_message and not selection_status:
info["scdm_selection_status"] = filter_message
enabled_labels = [ enabled_labels = [
str(spec.get("label") or spec.get("scdm_capability_key") or "").strip() str(spec.get("label") or spec.get("scdm_capability_key") or "").strip()
for spec in scdm_specs for spec in scdm_specs
@@ -3081,15 +3098,47 @@ class WindowStateMixin:
"请先导入模型,并输入类似 Face87.直径 = Face85.直径 的关系式。", "请先导入模型,并输入类似 Face87.直径 = Face85.直径 的关系式。",
) )
remove_button = getattr(self, "remove_relation_formula_button", None) remove_button = getattr(self, "remove_relation_formula_button", None)
toggle_button = getattr(self, "toggle_relation_formula_button", None)
formula_list = getattr(self, "relation_formula_list", None) formula_list = getattr(self, "relation_formula_list", None)
selected_ids = self._selected_relation_formula_ids()
selected_items = [
dict(item)
for item in (getattr(self, "relation_formula_items", []) or [])
if int(item.get("id", -1)) in selected_ids
]
selected_has_enabled = any(bool(item.get("enabled", True)) for item in selected_items)
if isinstance(toggle_button, QPushButton):
toggle_button.setText("停用公式" if selected_has_enabled else "启用公式")
self._set_control_state(
toggle_button,
bool(can_modify_formula_set and selected_items),
"临时停用或重新启用选中的关系式,并按当前启用公式重新计算模型。",
"请先在已有关系式列表里选择一条公式;公式计算中暂不能切换。",
)
if isinstance(remove_button, QPushButton): if isinstance(remove_button, QPushButton):
has_selected = bool(formula_list is not None and formula_list.selectedItems())
self._set_control_state( self._set_control_state(
remove_button, remove_button,
bool(can_modify_formula_set and has_selected), bool(can_modify_formula_set and selected_items),
"删除选中的关系式,并按剩余公式重新计算模型。", "删除选中的关系式,并按剩余公式重新计算模型。",
"请先在已有关系式列表里选择一条公式;公式计算中暂不能删除。", "请先在已有关系式列表里选择一条公式;公式计算中暂不能删除。",
) )
import_button = getattr(self, "import_relation_formula_button", None)
if isinstance(import_button, QPushButton):
self._set_control_state(
import_button,
bool(can_modify_formula_set),
"从 JSON 导入一组关系式,替换当前公式组,并按启用公式重新计算模型。",
"请先导入模型;公式计算中暂不能导入。",
)
export_button = getattr(self, "export_relation_formula_button", None)
if isinstance(export_button, QPushButton):
has_formulas = bool(getattr(self, "relation_formula_items", []) or [])
self._set_control_state(
export_button,
bool(has_formulas),
"把当前关系式导出为 JSON,便于复用或交给外部流程。",
"当前还没有可导出的关系式。",
)
def add_relation_formula(self) -> None: def add_relation_formula(self) -> None:
if self.model is None: if self.model is None:
@@ -3143,11 +3192,7 @@ class WindowStateMixin:
return return
if self._edit_busy("请等待当前几何计算完成后再删除公式。"): if self._edit_busy("请等待当前几何计算完成后再删除公式。"):
return return
ids = { ids = self._selected_relation_formula_ids()
int(item.data(Qt.ItemDataRole.UserRole))
for item in formula_list.selectedItems()
if item.data(Qt.ItemDataRole.UserRole) is not None
}
if not ids: if not ids:
return return
self.relation_formula_items = [ self.relation_formula_items = [
@@ -3157,15 +3202,218 @@ class WindowStateMixin:
] ]
self._refresh_relation_formula_list() self._refresh_relation_formula_list()
self._update_property_apply_state() self._update_property_apply_state()
if self.relation_formula_items: if self._enabled_relation_formula_items():
self.statusBar().showMessage(f"已删除 {len(ids)} 条关系式,正在按剩余公式更新模型...") self.statusBar().showMessage(f"已删除 {len(ids)} 条关系式,正在按剩余公式更新模型...")
self._start_relation_formula_reapply(reason="remove") self._start_relation_formula_reapply(reason="remove")
elif self.relation_formula_items:
restored = self._restore_relation_formula_base_snapshot()
message = "当前没有启用公式,模型已恢复到公式基准状态。" if restored else "当前没有启用公式。"
self.statusBar().showMessage(f"已删除 {len(ids)} 条关系式,{message}")
self._refresh_relation_formula_list()
self._update_property_apply_state()
else: else:
restored = self._restore_relation_formula_base_snapshot() restored = self._restore_relation_formula_base_snapshot()
self._clear_relation_formula_runtime_state(clear_items=False) self._clear_relation_formula_runtime_state(clear_items=False)
message = "模型已恢复到添加公式前状态。" if restored else "已删除关系式。" message = "模型已恢复到添加公式前状态。" if restored else "已删除关系式。"
self.statusBar().showMessage(f"已删除 {len(ids)} 条关系式,{message}") self.statusBar().showMessage(f"已删除 {len(ids)} 条关系式,{message}")
def toggle_selected_relation_formula(self) -> None:
formula_list = getattr(self, "relation_formula_list", None)
if formula_list is None:
return
if self._edit_busy("请等待当前几何计算完成后再切换公式。"):
return
ids = self._selected_relation_formula_ids()
if not ids:
return
selected_items = [
dict(item)
for item in (getattr(self, "relation_formula_items", []) or [])
if int(item.get("id", -1)) in ids
]
enable_selected = not any(bool(item.get("enabled", True)) for item in selected_items)
changed: list[dict[str, object]] = []
for raw_item in getattr(self, "relation_formula_items", []) or []:
item = dict(raw_item)
if int(item.get("id", -1)) in ids:
item["enabled"] = enable_selected
if enable_selected:
item["status"] = "ready"
item["message"] = "已启用,等待重新计算。"
else:
item["status"] = "disabled"
item["message"] = "已停用,不参与计算。"
changed.append(item)
self.relation_formula_items = changed
self._refresh_relation_formula_list()
self._update_property_apply_state()
action = "启用" if enable_selected else "停用"
if self._enabled_relation_formula_items():
self.statusBar().showMessage(f"{action} {len(ids)} 条关系式,正在按启用公式更新模型...")
self._start_relation_formula_reapply(reason="toggle")
else:
restored = self._restore_relation_formula_base_snapshot()
message = "模型已恢复到公式基准状态。" if restored else "当前没有启用公式。"
self.statusBar().showMessage(f"{action} {len(ids)} 条关系式,{message}")
self._refresh_relation_formula_list()
self._update_property_apply_state()
def export_relation_formulas(self, path: str | Path | bool | None = None) -> bool:
interactive = path is None or isinstance(path, bool)
if isinstance(path, bool):
path = None
formulas = list(getattr(self, "relation_formula_items", []) or [])
if not formulas:
self.statusBar().showMessage("当前没有可导出的关系式。")
if interactive:
QMessageBox.information(self, "导出公式", "当前没有可导出的关系式。")
return False
if path is None:
start_dir = Path(getattr(self, "step_path", "") or Path.cwd()).parent
selected, _ = QFileDialog.getSaveFileName(
self,
"导出关系式",
str(start_dir / "relation_formulas.json"),
"JSON (*.json);;所有文件 (*.*)",
)
if not selected:
return False
path = selected
output_path = Path(path)
output_path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"schema": "python-occt.relation-formulas.v1",
"exportedAt": datetime.now().isoformat(timespec="seconds"),
"sourceStep": str(getattr(self, "step_path", "") or ""),
"formulas": self._relation_formula_export_rows(),
}
output_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
self.statusBar().showMessage(f"已导出 {len(payload['formulas'])} 条关系式:{output_path}")
return True
def import_relation_formulas(self, path: str | Path | bool | None = None) -> bool:
interactive = path is None or isinstance(path, bool)
if isinstance(path, bool):
path = None
if self.model is None:
self.statusBar().showMessage("请先导入模型后再导入关系式。")
if interactive:
QMessageBox.information(self, "导入公式", "请先导入模型后再导入关系式。")
return False
if self._edit_busy("请等待当前几何计算完成后再导入公式。"):
return False
if path is None:
start_dir = Path(getattr(self, "step_path", "") or Path.cwd()).parent
selected, _ = QFileDialog.getOpenFileName(
self,
"导入关系式",
str(start_dir),
"JSON (*.json);;所有文件 (*.*)",
)
if not selected:
return False
path = selected
input_path = Path(path)
try:
payload = json.loads(input_path.read_text(encoding="utf-8"))
imported_items = self._relation_formula_items_from_import_payload(payload)
except Exception as exc:
message = f"导入公式失败:{exc}"
self.statusBar().showMessage(message)
if interactive:
QMessageBox.information(self, "导入公式失败", message)
return False
self._ensure_relation_formula_base_snapshot()
self.relation_formula_items = imported_items
next_ids = [int(item.get("id", 0) or 0) for item in imported_items]
self.relation_formula_next_id = (max(next_ids) + 1) if next_ids else 1
self._refresh_relation_formula_list()
self._update_property_apply_state()
enabled_count = len(self._enabled_relation_formula_items())
if enabled_count:
self.statusBar().showMessage(f"已导入 {len(imported_items)} 条关系式,正在按 {enabled_count} 条启用公式更新模型...")
self._start_relation_formula_reapply(reason="import")
else:
self.statusBar().showMessage(f"已导入 {len(imported_items)} 条关系式;当前没有启用公式。")
return True
def _relation_formula_export_rows(self) -> list[dict[str, object]]:
rows: list[dict[str, object]] = []
for index, raw_item in enumerate(getattr(self, "relation_formula_items", []) or [], start=1):
item = dict(raw_item)
rows.append(
{
"index": index,
"text": str(item.get("text") or "").strip(),
"enabled": bool(item.get("enabled", True)),
"status": str(item.get("status") or "").strip(),
"message": str(item.get("message") or "").strip(),
}
)
return rows
def _relation_formula_items_from_import_payload(self, payload: object) -> list[dict[str, object]]:
raw_items = payload.get("formulas") if isinstance(payload, dict) else payload
if not isinstance(raw_items, list):
raise RelationFormulaError("JSON 必须是公式数组,或包含 formulas 数组。")
formulas = []
items: list[dict[str, object]] = []
for index, raw_item in enumerate(raw_items, start=1):
if isinstance(raw_item, str):
text = raw_item
enabled = True
elif isinstance(raw_item, dict):
text = str(raw_item.get("text") or raw_item.get("formula") or "").strip()
enabled = bool(raw_item.get("enabled", True))
else:
raise RelationFormulaError(f"{index} 条公式格式不正确。")
if not text:
raise RelationFormulaError(f"{index} 条公式为空。")
try:
formula = parse_relation_formula(text)
except RelationFormulaError as exc:
raise RelationFormulaError(f"{index} 条公式无效:{exc}") from exc
formulas.append(formula)
items.append(
{
"id": index,
"text": formula.text,
"enabled": enabled,
"status": "ready" if enabled else "disabled",
"message": "导入后等待计算。" if enabled else "已停用,不参与计算。",
"signatures": {},
}
)
if not items:
raise RelationFormulaError("JSON 里没有关系式。")
try:
validate_relation_formula_graph(formulas)
except RelationFormulaError as exc:
raise RelationFormulaError(f"导入的公式组无效:{exc}") from exc
return items
def _selected_relation_formula_ids(self) -> set[int]:
formula_list = getattr(self, "relation_formula_list", None)
if formula_list is None:
return set()
ids: set[int] = set()
for item in formula_list.selectedItems():
value = item.data(Qt.ItemDataRole.UserRole)
if value is None:
continue
try:
ids.add(int(value))
except (TypeError, ValueError):
continue
return ids
def _enabled_relation_formula_items(self) -> list[dict[str, object]]:
return [
dict(item)
for item in (getattr(self, "relation_formula_items", []) or [])
if bool(item.get("enabled", True))
]
def _clear_relation_formula_runtime_state(self, *, clear_items: bool) -> None: def _clear_relation_formula_runtime_state(self, *, clear_items: bool) -> None:
if clear_items: if clear_items:
self.relation_formula_items = [] self.relation_formula_items = []
@@ -3264,6 +3512,10 @@ class WindowStateMixin:
label = "添加" label = "添加"
elif reason == "dependency": elif reason == "dependency":
label = "依赖参数变化" label = "依赖参数变化"
elif reason == "toggle":
label = "切换"
elif reason == "import":
label = "导入"
else: else:
label = "删除" label = "删除"
self.statusBar().showMessage(f"{label}公式后正在重新计算:共 {self.relation_formula_replay_total} 条。") self.statusBar().showMessage(f"{label}公式后正在重新计算:共 {self.relation_formula_replay_total} 条。")
@@ -3424,7 +3676,10 @@ class WindowStateMixin:
message = str(item.get("message") or "").strip() message = str(item.get("message") or "").strip()
text = str(item.get("text") or "") text = str(item.get("text") or "")
suffix = "" suffix = ""
if status == "invalid": enabled = bool(item.get("enabled", True))
if not enabled:
suffix = f" [停用:{message or '不参与计算'}]"
elif status == "invalid":
suffix = f" [失效:{message or '需要重新选择'}]" suffix = f" [失效:{message or '需要重新选择'}]"
elif status == "pending": elif status == "pending":
suffix = f" [待选择:{message or '目标不在当前参数表'}]" suffix = f" [待选择:{message or '目标不在当前参数表'}]"
@@ -3435,7 +3690,9 @@ class WindowStateMixin:
row_item = QListWidgetItem(f"{text}{suffix}") row_item = QListWidgetItem(f"{text}{suffix}")
row_item.setData(Qt.ItemDataRole.UserRole, int(item.get("id", -1))) row_item.setData(Qt.ItemDataRole.UserRole, int(item.get("id", -1)))
row_item.setToolTip(message or text) row_item.setToolTip(message or text)
if status == "invalid": if not enabled:
row_item.setForeground(QColor("#6b7280"))
elif status == "invalid":
row_item.setForeground(QColor("#b91c1c")) row_item.setForeground(QColor("#b91c1c"))
elif status == "pending": elif status == "pending":
row_item.setForeground(QColor("#92400e")) row_item.setForeground(QColor("#92400e"))
@@ -4527,6 +4784,7 @@ class WindowStateMixin:
if self.selected_kind == "multi_feature" and str(action_info.get("multi_selection_kind") or "") == "holes": if self.selected_kind == "multi_feature" and str(action_info.get("multi_selection_kind") or "") == "holes":
return self._multi_hole_property_specs(action_info) return self._multi_hole_property_specs(action_info)
scdm_specs = self._scdm_property_specs_for_selection() scdm_specs = self._scdm_property_specs_for_selection()
scdm_specs, scdm_filter_message = self._filter_scdm_specs_for_local_recognition(scdm_specs, action_info)
if self._prefer_local_face_offset_over_scdm(scdm_specs, action_info): if self._prefer_local_face_offset_over_scdm(scdm_specs, action_info):
scdm_specs = [ scdm_specs = [
spec spec
@@ -4538,9 +4796,17 @@ class WindowStateMixin:
"避免 SCDM 长时间直接建模计算。" "避免 SCDM 长时间直接建模计算。"
) )
else: else:
self.scdm_selection_status_message = self._scdm_selection_status_message(scdm_specs) self.scdm_selection_status_message = scdm_filter_message or self._scdm_selection_status_message(scdm_specs)
if scdm_specs and any(bool(spec.get("enabled")) for spec in scdm_specs): if scdm_specs and any(bool(spec.get("enabled")) for spec in scdm_specs):
return [spec for spec in scdm_specs if bool(spec.get("enabled"))] return [spec for spec in scdm_specs if bool(spec.get("enabled"))]
if scdm_filter_message:
return []
if self._should_hold_local_cylindrical_specs_for_scdm(action_info):
self.scdm_selection_status_message = (
"SCDM 正在按需识别当前大模型对象;圆柱、槽、圆角这类容易误分的对象,"
"识别完成前不开放本地兜底孔/槽参数。"
)
return []
editable_specs, _used_keys = self._editable_property_specs(action_info) editable_specs, _used_keys = self._editable_property_specs(action_info)
if self.selected_kind == "feature": if self.selected_kind == "feature":
return self._feature_context_property_specs(editable_specs, action_info) return self._feature_context_property_specs(editable_specs, action_info)
@@ -4554,6 +4820,46 @@ class WindowStateMixin:
and _property_parameter_is_visible(spec) and _property_parameter_is_visible(spec)
] ]
def _filter_scdm_specs_for_local_recognition(
self,
scdm_specs: list[dict[str, object]],
action_info: dict[str, object],
) -> tuple[list[dict[str, object]], str]:
if not scdm_specs or self.selected_kind not in {"feature", "face"}:
return scdm_specs, ""
conflicting_keys = {"round.radius", "chamfer.distance", "feature.delete_round_or_chamfer"}
if not any(str(spec.get("scdm_capability_key") or "") in conflicting_keys for spec in scdm_specs):
return scdm_specs, ""
feature_guess = str(action_info.get("feature_guess") or "").strip()
feature_type = str(action_info.get("feature_type") or "").strip()
if feature_guess in {"round/fillet candidate", "chamfer candidate"} or any(
token in feature_type for token in ("圆角", "倒圆", "倒角")
):
return scdm_specs, ""
local_slot_or_pocket = bool(
action_info.get("blind_split_cylindrical_pocket")
or action_info.get("blind_split_cylindrical_groove")
or "复杂槽" in feature_type
or ("" in feature_type and "圆柱孔" not in feature_type)
)
if not local_slot_or_pocket:
return scdm_specs, ""
filtered = [
spec
for spec in scdm_specs
if str(spec.get("scdm_capability_key") or "") not in conflicting_keys
]
if len(filtered) == len(scdm_specs):
return scdm_specs, ""
return (
filtered,
"SCDM 返回了圆角/倒角删除能力,但当前对象本地识别为盲孔/槽凹坑或复杂槽;"
"为避免把槽误删成圆角,已隐藏该冲突操作。",
)
def _prefer_local_face_offset_over_scdm( def _prefer_local_face_offset_over_scdm(
self, self,
scdm_specs: list[dict[str, object]], scdm_specs: list[dict[str, object]],
@@ -4578,6 +4884,35 @@ class WindowStateMixin:
) or 0 ) or 0
return inner_wires > 0 or boundary_edges >= 16 return inner_wires > 0 or boundary_edges >= 16
def _should_hold_local_cylindrical_specs_for_scdm(self, action_info: dict[str, object]) -> bool:
if self.selected_kind not in {"feature", "face"} or self.model is None:
return False
if not bool(getattr(self, "_large_model_interaction_mode", lambda: False)()):
return False
state = str(getattr(self, "scdm_feature_cache_state", "") or "")
if state not in {"deferred", "running", "empty", "stale"}:
return False
surface = str(action_info.get("surface") or "").strip().lower()
if surface == "cylinder":
return True
feature_guess = str(action_info.get("feature_guess") or "").strip().lower()
feature_type = str(action_info.get("feature_type") or "").strip()
cylindrical_markers = (
"cylindrical_feature_side_face_count",
"diameter",
"radius",
"axis",
"axis_point",
"slot_chord_width_estimate",
"slot_sagitta_depth_estimate",
"existing_fillet_radius_estimate",
)
if any(action_info.get(key) is not None and str(action_info.get(key)) != "" for key in cylindrical_markers):
return True
return any(token in feature_guess for token in ("hole", "groove", "round", "fillet", "boss")) or any(
token in feature_type for token in ("", "", "圆角", "倒圆", "凸台", "外圆")
)
def _scdm_selection_status_message(self, scdm_specs: list[dict[str, object]]) -> str: def _scdm_selection_status_message(self, scdm_specs: list[dict[str, object]]) -> str:
if self.selected_kind not in {"feature", "face", "edge", "solid", "part"}: if self.selected_kind not in {"feature", "face", "edge", "solid", "part"}:
return "" return ""
@@ -4617,16 +4952,20 @@ class WindowStateMixin:
return [] return []
face_ids: set[int] = set() face_ids: set[int] = set()
edge_ids: set[int] = set() edge_ids: set[int] = set()
solid_ids: set[int] = set()
if self.selected_kind in {"feature", "face"} and self.selected_face_id is not None: if self.selected_kind in {"feature", "face"} and self.selected_face_id is not None:
face_ids.update(self._scdm_selection_face_match_ids(int(self.selected_face_id))) face_ids.update(self._scdm_selection_face_match_ids(int(self.selected_face_id)))
if self.selected_kind == "edge" and self.selected_edge_id is not None: if self.selected_kind == "edge" and self.selected_edge_id is not None:
edge_ids.add(int(self.selected_edge_id)) edge_ids.add(int(self.selected_edge_id))
if not face_ids and not edge_ids: if self.selected_kind == "solid" and self.selected_solid_id is not None:
solid_ids.add(int(self.selected_solid_id))
if not face_ids and not edge_ids and not solid_ids:
return [] return []
return property_specs_from_scdm_cache( return property_specs_from_scdm_cache(
cache, cache,
selected_face_ids=face_ids, selected_face_ids=face_ids,
selected_edge_ids=edge_ids, selected_edge_ids=edge_ids,
selected_solid_ids=solid_ids,
execution_ready=getattr(self, "scdm_edit_runner_ready", False), execution_ready=getattr(self, "scdm_edit_runner_ready", False),
) )
@@ -4917,7 +5256,8 @@ class WindowStateMixin:
) -> list[dict[str, object]]: ) -> list[dict[str, object]]:
def no_editable_feature_dimensions_spec() -> dict[str, object]: def no_editable_feature_dimensions_spec() -> dict[str, object]:
reason = str( reason = str(
action_info.get("freeform_face_blockers") self._no_editable_feature_dimension_reason(action_info)
or action_info.get("freeform_face_blockers")
or action_info.get("recognition_blockers") or action_info.get("recognition_blockers")
or action_info.get("local_face_deform_blocker") or action_info.get("local_face_deform_blocker")
or action_info.get("feature_edit_actions") or action_info.get("feature_edit_actions")
@@ -4925,6 +5265,9 @@ class WindowStateMixin:
).strip() ).strip()
if not reason: if not reason:
reason = "当前识别结果没有稳定可修改参数;详细原因请看诊断信息。" reason = "当前识别结果没有稳定可修改参数;详细原因请看诊断信息。"
existing_message = str(getattr(self, "scdm_selection_status_message", "") or "").strip()
if not existing_message or existing_message.startswith("SCDM 未在当前对象上识别到"):
self.scdm_selection_status_message = reason
return { return {
"key": "no_editable_feature_dimensions", "key": "no_editable_feature_dimensions",
"label": "可修改参数", "label": "可修改参数",
@@ -4968,6 +5311,51 @@ class WindowStateMixin:
rows = root_dimensions + related_rows rows = root_dimensions + related_rows
return rows or [no_editable_feature_dimensions_spec()] return rows or [no_editable_feature_dimensions_spec()]
def _no_editable_feature_dimension_reason(self, action_info: dict[str, object]) -> str:
feature_type = str(action_info.get("feature_type") or "").strip()
feature_guess = str(action_info.get("feature_guess") or "").strip()
surface = str(action_info.get("surface") or "").strip()
slot_status = str(action_info.get("slot_status") or "").strip()
slot_blockers = str(action_info.get("slot_blockers") or action_info.get("recognition_blockers") or "").strip()
slotish = bool(
"" in feature_type
or bool(action_info.get("blind_split_cylindrical_groove"))
or bool(action_info.get("blind_split_cylindrical_pocket"))
or (
surface == "cylinder"
and feature_guess == "hole/groove candidate"
and not _is_effectively_full_cylinder(action_info)
)
)
if not slotish:
return ""
if slot_status == "blocked":
return slot_blockers or "当前槽属于复杂槽/多槽组,暂时不能稳定修改。"
if slot_status != "candidate":
state = str(getattr(self, "scdm_feature_cache_state", "") or "").strip()
if state in {"deferred", "running", "empty", "stale"}:
return (
"当前对象只识别为圆柱孔/槽候选,还没有确认成可编辑的简单槽/长圆槽;"
"需要等 SCDM 识别或本地计划拿到稳定的槽宽、槽深、槽底面/方向或成对端面证据后,才会显示槽参数。"
)
return (
"当前对象只识别为圆柱孔/槽候选,SCDM/cache 没有返回可执行的槽宽、槽深或槽位置能力;"
"软件进度里的槽编辑只针对证据完整的简单槽/长圆槽。"
)
missing: list[str] = []
if _float_or_none(action_info.get("slot_chord_width_estimate")) is None:
missing.append("槽宽")
if _float_or_none(action_info.get("slot_sagitta_depth_estimate")) is None:
missing.append("槽深")
if not _int_values(action_info.get("feature_bottom_face_ids")) and not _int_values(action_info.get("feature_slot_face_ids")):
missing.append("槽底面/槽组")
if missing:
return (
f"当前槽候选缺少可稳定执行的{''.join(missing)}证据;"
"为避免把复杂槽误改坏,暂不在参数表开放槽参数。"
)
return ""
def _ordered_property_info_items(self, info: dict[str, object]) -> list[tuple[str, object]]: def _ordered_property_info_items(self, info: dict[str, object]) -> list[tuple[str, object]]:
items = self._ordered_info_items(info) items = self._ordered_info_items(info)
if not self._is_feature_like_info(info): if not self._is_feature_like_info(info):
@@ -8673,6 +9061,8 @@ class WindowStateMixin:
return return
item = queue.pop(0) item = queue.pop(0)
self.property_batch_queue = queue self.property_batch_queue = queue
# 每改完一个参数都会重新加载/识别模型,原来的表格行可能已经失效;
# 所以下一个批量项必须用 key/能力信息重新定位当前行。
row = self._property_batch_row_for_item(item) row = self._property_batch_row_for_item(item)
if row is None: if row is None:
self._clear_property_batch_state() self._clear_property_batch_state()
@@ -8895,6 +9285,8 @@ class WindowStateMixin:
thread = QThread(self) thread = QThread(self)
def action() -> dict[str, object]: def action() -> dict[str, object]:
# Worker 线程只做外部 SCDM 调用和文件等待,不碰 Qt 控件和当前 StepModel。
# 完成后的模型替换、弹窗、cache 刷新都在 queued slot 里回 UI 线程处理。
return run_scdm_edit_job( return run_scdm_edit_job(
source_step, source_step,
capability_key=capability_key, capability_key=capability_key,
@@ -9018,7 +9410,26 @@ class WindowStateMixin:
} }
self._end_edit_task(clear_preview=True) self._end_edit_task(clear_preview=True)
self.statusBar().showMessage("SCDM 修改完成,正在重新读取结果模型...") self.statusBar().showMessage("SCDM 修改完成,正在重新读取结果模型...")
self.load_step(output_step, background=True) # 先把 result.step 加载到当前模型,再进入 _finish_pending_scdm_edit_reload。
# 只有加载后的 OCCT B-Rep 和新 SCDM cache 都通过校验,才真正记录历史。
loaded = False
if hasattr(self, "_load_step_sync"):
loaded = bool(
self._load_step_sync(
output_step,
deflection=float(getattr(self, "preview_load_deflection", 0.35) or 0.35),
show_internal_edges=bool(self._show_same_domain_internal_edges()) if hasattr(self, "_show_same_domain_internal_edges") else False,
status_prefix="读取 SCDM 修改结果",
defer_edges=True,
reset_camera=False,
)
)
else:
self.load_step(output_step, background=False)
loaded = True
if not loaded and isinstance(getattr(self, "pending_scdm_edit_reload", None), dict):
self.pending_scdm_edit_reload = None
self._after_property_edit_finished(success=False)
@Slot(str) @Slot(str)
def _fail_scdm_edit_action(self, message: str) -> None: def _fail_scdm_edit_action(self, message: str) -> None:
@@ -9052,6 +9463,7 @@ class WindowStateMixin:
return return
if isinstance(before_cache, dict) and isinstance(after_cache, dict): if isinstance(before_cache, dict) and isinstance(after_cache, dict):
signature = pending.get("object_signature") signature = pending.get("object_signature")
# 结果模型已经显示出来了,但还没承诺成功;验证失败会立即恢复修改前快照。
validation = validate_scdm_edit_result( validation = validate_scdm_edit_result(
pending.get("edit_result") if isinstance(pending.get("edit_result"), dict) else {}, pending.get("edit_result") if isinstance(pending.get("edit_result"), dict) else {},
before_signature=signature if isinstance(signature, dict) else None, before_signature=signature if isinstance(signature, dict) else None,