feat: 完善 SCDM-first 参数化编辑交付版
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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.半径"], "同一目标参数")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 样例回测尚未完成。",
|
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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", "对象")):
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user