From 70b05349644dd464956be9543b40e912dd9cbd09 Mon Sep 17 00:00:00 2001 From: Zhaowenlong Date: Wed, 20 May 2026 20:37:24 +0800 Subject: [PATCH] =?UTF-8?q?feature/=E8=AE=BE=E5=A4=87=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E7=AB=AF=E5=AD=90=E5=88=B6=E4=BD=9C=E9=9D=A2=E6=9D=BF-zwl-0520?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/FreeCAD 二次开发说明.md | 25 ++ ...子显示连线保存回写开发文档.md | 23 ++ src/Mod/FreeCADExchange/CMakeLists.txt | 1 + src/Mod/FreeCADExchange/InitGui.py | 6 + .../FreeCADExchange/TemplateAuthoringPanel.py | 260 ++++++++++++++++++ ..._exchange_template_authoring_panel_test.py | 85 ++++++ 6 files changed, 400 insertions(+) create mode 100644 src/Mod/FreeCADExchange/TemplateAuthoringPanel.py create mode 100644 tests/python/freecad_exchange_template_authoring_panel_test.py diff --git a/docs/FreeCAD 二次开发说明.md b/docs/FreeCAD 二次开发说明.md index 20e8017..76fcfd5 100644 --- a/docs/FreeCAD 二次开发说明.md +++ b/docs/FreeCAD 二次开发说明.md @@ -283,6 +283,31 @@ A 方案要求 `D:\code\zwl` 支持 `.FCStd` 作为设备 3D 资产格式,但 - `docs/superpowers/specs/2026-05-20-freecad-fcstd-asset-flow-design.md` +### 13.3 面向 CAD 人员的模板制作交互 + +Python 控制台方式只作为开发验证手段,不能作为正式 CAD 工作流。A 方案第一版需要补一个 FreeCAD 任务面板,把端子制作流程封装成按钮和输入框。 + +推荐交互: + +1. 用户打开 STEP / STP / STE 模型。 +2. 打开“设备模板端子制作”面板。 +3. 在端子名输入框填写 `P1`。 +4. 用鼠标选中模型上的端子位置。 +5. 点击“添加端子”。 +6. 重复添加 `P2` 等端子。 +7. 点击“校验端子”。 +8. 点击“保存为 FCStd”。 + +面板背后仍然使用 LCS 作为端子对象,仍然写入: + +- `Role = "Terminal"` +- `CanWire = true` +- `QetTemplateSlotName` +- `QetTerminalLabel` +- `QetTerminalType` + +这样既保持当前技术路线稳定,又让非开发人员可以直接使用。 + ## 14. 电气柜与设备装配 除了端子与接线,电气柜场景通常还会遇到另一个基础问题: diff --git a/docs/FreeCAD 端子显示连线保存回写开发文档.md b/docs/FreeCAD 端子显示连线保存回写开发文档.md index b2b75e4..5423e9f 100644 --- a/docs/FreeCAD 端子显示连线保存回写开发文档.md +++ b/docs/FreeCAD 端子显示连线保存回写开发文档.md @@ -182,6 +182,26 @@ FCStd 设备模板用于解决“这个模型本身就带端子语义”的问 4. 保存为 `.FCStd` 设备模板。 5. 后续 LightWork3D 工程引用该 `.FCStd` 后,自动识别模板端子并生成工程端子对象。 +### 3.2.1 方案 2:设备模板端子制作面板 + +当前 Python 控制台方式只适合开发验证,不适合 CAD 工作人员。下一步开发目标改为在 FreeCAD 右侧任务区提供“设备模板端子制作”面板。 + +第一版面板能力: + +- 显示当前文档中的模板端子列表。 +- 输入端子名,例如 `P1`、`P2`。 +- 用户选择模型上的孔、点或对象后,点击“添加端子”。 +- 点击“校验端子”显示总数、有效数和警告。 +- 点击“保存为 FCStd”选择路径并保存模板。 + +该面板只包装现有 Python 能力,不改 FreeCAD C/C++ 源码: + +- `TemplateAuthoring.create_template_terminal` +- `TemplateAuthoring.validate_template_terminals` +- `TemplateAuthoring.save_template_as_fcstd` + +目标是让 CAD 工作人员只通过鼠标选择和按钮点击完成模板制作,不再直接输入 Python 代码。 + ### 3.3 A 方案资产流转约定 A 方案下,`.FCStd` 是正式可复用设备资产,STEP / STP / STE 只作为模板制作的原始几何输入。 @@ -297,6 +317,7 @@ FreeCADExchange/ DeviceImport.py # 设备导入,已存在 TemplateSemantics.py # 新增:读取 FCStd LCS 或 STEP sidecar 端子槽位 TemplateAuthoring.py # 计划新增:把 STEP/STP/STE 制作为带端子语义的 FCStd 模板 + TemplateAuthoringPanel.py # 新增:CAD 人员使用的端子制作任务面板 TerminalImport.py # 新增:根据 terminals 创建/更新端子对象 TerminalObjects.py # 新增:端子对象属性、查找、校验工具 ManualWiring.py # 新增:端子选择、折线路径创建、连线对象属性 @@ -575,4 +596,6 @@ ManualWiring.py - 2026-05-20:补充 A 方案资产流转设计,明确 `.FCStd` 为正式设备资产;zwl/QET 只负责选择、保存、导出 `.FCStd` 路径,FreeCADExchange 负责读取 LCS 端子语义并生成工程端子。 - 2026-05-20:补上 `QET_Template_SaveAsFCStd` 模板保存命令,保存前会校验至少存在一个有效模板端子,并自动补 `.FCStd` 后缀;已用单元测试验证保存路径和端子校验结果。 - 2026-05-20:修复 `TemplateAuthoring.py` 在 `FreeCADCmd.exe` 命令行模式下导入时误注册 GUI 命令的问题;已在运行目录验证创建 `P1` 模板端子、保存 `.FCStd`、重新打开后端子语义仍可识别。 +- 2026-05-20:确定方案 2 开发目标:新增“设备模板端子制作”任务面板,让 CAD 工作人员通过输入端子名、选择模型位置、点击按钮完成添加端子、校验端子和保存 FCStd,不再依赖 Python 控制台。 +- 2026-05-20:新增 `TemplateAuthoringPanel.py`,提供“设备模板端子制作”任务面板和 `QET_Template_OpenAuthoringPanel` 命令;面板支持输入端子名、添加端子、校验端子、保存 FCStd,并已同步到运行目录验证模块可导入。 ``` diff --git a/src/Mod/FreeCADExchange/CMakeLists.txt b/src/Mod/FreeCADExchange/CMakeLists.txt index 77254a2..f9684bd 100644 --- a/src/Mod/FreeCADExchange/CMakeLists.txt +++ b/src/Mod/FreeCADExchange/CMakeLists.txt @@ -8,6 +8,7 @@ set(FreeCADExchange_Scripts TerminalObjects.py TemplateSemantics.py TemplateAuthoring.py + TemplateAuthoringPanel.py TerminalImport.py ExchangeWriteBack.py ManualWiring.py diff --git a/src/Mod/FreeCADExchange/InitGui.py b/src/Mod/FreeCADExchange/InitGui.py index 09fe20c..ed607e2 100644 --- a/src/Mod/FreeCADExchange/InitGui.py +++ b/src/Mod/FreeCADExchange/InitGui.py @@ -15,6 +15,7 @@ import ExchangeBootstrap import ExchangeWriteBack import ManualWiring import TemplateAuthoring +import TemplateAuthoringPanel def _append_init_log(message): @@ -53,5 +54,10 @@ try: except Exception: pass +try: + TemplateAuthoringPanel.register_commands() +except Exception: + pass + QtCore.QTimer.singleShot(0, ExchangeBootstrap.bootstrap_if_requested) diff --git a/src/Mod/FreeCADExchange/TemplateAuthoringPanel.py b/src/Mod/FreeCADExchange/TemplateAuthoringPanel.py new file mode 100644 index 0000000..4dd68b3 --- /dev/null +++ b/src/Mod/FreeCADExchange/TemplateAuthoringPanel.py @@ -0,0 +1,260 @@ +# FreeCADExchange GUI panel for FCStd equipment template authoring. + +import FreeCAD as App + +try: + import FreeCADGui as Gui +except ImportError: + Gui = None + +try: + from PySide6 import QtGui, QtWidgets +except ImportError: + try: + from PySide2 import QtGui, QtWidgets + except ImportError: + try: + from PySide import QtGui + from PySide import QtGui as QtWidgets + except ImportError: + QtGui = None + QtWidgets = None + +import TemplateAuthoring + + +COMMAND_NAME = "QET_Template_OpenAuthoringPanel" +MENU_ACTION_OBJECT_NAME = "QET_Template_OpenAuthoringPanel_MenuAction" + + +def next_slot_name(report): + terminals = list((report or {}).get("terminals", []) or []) + return "T{0}".format(len(terminals) + 1) + + +def terminal_list_text(report): + rows = [] + for item in list((report or {}).get("terminals", []) or []): + slot_name = (item.get("slot_name", "") or "").strip() or "(unnamed)" + object_name = (item.get("name", "") or "").strip() or "(object)" + suffix = "" if item.get("can_wire", False) and slot_name != "(unnamed)" else " [invalid]" + rows.append("{0} - {1}{2}".format(slot_name, object_name, suffix)) + return rows + + +class TemplateAuthoringTaskPanel: + def __init__(self): + if QtWidgets is None: + raise TemplateAuthoring.TemplateAuthoringError("Qt widgets are not available.") + + self.form = QtWidgets.QWidget() + self.form.setWindowTitle("设备模板端子制作") + + layout = QtWidgets.QVBoxLayout(self.form) + + name_row = QtWidgets.QHBoxLayout() + name_row.addWidget(QtWidgets.QLabel("端子名")) + self.slot_name_edit = QtWidgets.QLineEdit() + self.slot_name_edit.setPlaceholderText("P1") + name_row.addWidget(self.slot_name_edit) + layout.addLayout(name_row) + + type_row = QtWidgets.QHBoxLayout() + type_row.addWidget(QtWidgets.QLabel("端子类型")) + self.terminal_type_combo = QtWidgets.QComboBox() + self.terminal_type_combo.addItems(["generic", "primary", "power", "control"]) + type_row.addWidget(self.terminal_type_combo) + layout.addLayout(type_row) + + self.add_button = QtWidgets.QPushButton("添加端子") + self.validate_button = QtWidgets.QPushButton("校验端子") + self.save_button = QtWidgets.QPushButton("保存为 FCStd") + self.refresh_button = QtWidgets.QPushButton("刷新列表") + + layout.addWidget(self.add_button) + layout.addWidget(self.validate_button) + layout.addWidget(self.save_button) + layout.addWidget(self.refresh_button) + + self.terminal_list = QtWidgets.QListWidget() + layout.addWidget(self.terminal_list) + + self.status_label = QtWidgets.QLabel("") + self.status_label.setWordWrap(True) + layout.addWidget(self.status_label) + + self.add_button.clicked.connect(self.add_terminal) + self.validate_button.clicked.connect(self.validate_terminals) + self.save_button.clicked.connect(self.save_template) + self.refresh_button.clicked.connect(self.refresh) + + self.refresh() + + def _document(self): + doc = getattr(App, "ActiveDocument", None) + if doc is None: + raise TemplateAuthoring.TemplateAuthoringError("请先打开或创建一个 FreeCAD 文档。") + return doc + + def _set_status(self, message): + self.status_label.setText(message) + try: + App.Console.PrintMessage("[FreeCADExchange] {0}\n".format(message)) + except Exception: + pass + + def _set_error(self, message): + self.status_label.setText(message) + try: + App.Console.PrintError("[FreeCADExchange] {0}\n".format(message)) + except Exception: + pass + + def refresh(self): + try: + report = TemplateAuthoring.validate_template_terminals(getattr(App, "ActiveDocument", None)) + except Exception as exc: + self._set_error(str(exc)) + return + + self.terminal_list.clear() + for row in terminal_list_text(report): + self.terminal_list.addItem(row) + + if not self.slot_name_edit.text().strip(): + self.slot_name_edit.setText(next_slot_name(report)) + + self.status_label.setText( + "端子:{0} 个,有效:{1} 个".format( + report.get("total_terminals", 0), + report.get("valid_terminals", 0), + ) + ) + + def add_terminal(self): + try: + doc = self._document() + slot_name = self.slot_name_edit.text().strip() + position = TemplateAuthoring._selection_position() + if position is None: + raise TemplateAuthoring.TemplateAuthoringError("请先在模型上选择端子位置。") + terminal_type = self.terminal_type_combo.currentText().strip() or "generic" + TemplateAuthoring.create_template_terminal( + doc, + slot_name, + position, + terminal_type=terminal_type, + ) + self.slot_name_edit.clear() + self.refresh() + self._set_status("已添加端子:{0}".format(slot_name)) + except Exception as exc: + self._set_error(str(exc)) + + def validate_terminals(self): + try: + report = TemplateAuthoring.validate_template_terminals(self._document()) + self.refresh() + if report.get("warnings"): + self._set_error("校验发现问题:{0}".format("; ".join(report["warnings"]))) + else: + self._set_status( + "校验通过:{0}/{1} 个端子有效".format( + report.get("valid_terminals", 0), + report.get("total_terminals", 0), + ) + ) + except Exception as exc: + self._set_error(str(exc)) + + def save_template(self): + try: + doc = self._document() + file_path, _selected_filter = QtWidgets.QFileDialog.getSaveFileName( + self.form, + "保存 FCStd 设备模板", + "", + "FreeCAD template (*.FCStd *.fcstd);;All files (*.*)", + ) + if not file_path: + return + report = TemplateAuthoring.save_template_as_fcstd(doc, file_path) + self.refresh() + self._set_status("已保存模板:{0}".format(report["path"])) + except Exception as exc: + self._set_error(str(exc)) + + def accept(self): + return True + + def reject(self): + return True + + +class CommandOpenTemplateAuthoringPanel: + def GetResources(self): + return { + "MenuText": "设备模板端子制作", + "ToolTip": "打开 FCStd 设备模板端子制作面板", + } + + def IsActive(self): + return getattr(App, "ActiveDocument", None) is not None and Gui is not None + + def Activated(self): + if Gui is None or not hasattr(Gui, "Control"): + return + if hasattr(Gui.Control, "activeDialog") and Gui.Control.activeDialog(): + Gui.Control.closeDialog() + Gui.Control.showDialog(TemplateAuthoringTaskPanel()) + + +_COMMANDS_REGISTERED = False +_MENU_ACTION_INSTALLED = False + + +def _tools_menu(): + if Gui is None or QtWidgets is None or QtGui is None: + return None + if not hasattr(Gui, "getMainWindow"): + return None + main_window = Gui.getMainWindow() + if main_window is None: + return None + menu_bar = main_window.menuBar() + for action in menu_bar.actions(): + menu = action.menu() + if menu is not None and action.text().replace("&", "") in {"工具", "Tools"}: + return menu + return menu_bar.addMenu("工具") + + +def install_menu_action(): + global _MENU_ACTION_INSTALLED + if _MENU_ACTION_INSTALLED: + return + menu = _tools_menu() + if menu is None or QtGui is None: + return + for action in menu.actions(): + if action.objectName() == MENU_ACTION_OBJECT_NAME: + _MENU_ACTION_INSTALLED = True + return + action = QtGui.QAction("设备模板端子制作", menu) + action.setObjectName(MENU_ACTION_OBJECT_NAME) + action.triggered.connect(lambda: Gui.runCommand(COMMAND_NAME)) + menu.addAction(action) + _MENU_ACTION_INSTALLED = True + + +def register_commands(): + global _COMMANDS_REGISTERED + if Gui is None or not hasattr(Gui, "addCommand"): + return + if not _COMMANDS_REGISTERED: + Gui.addCommand(COMMAND_NAME, CommandOpenTemplateAuthoringPanel()) + _COMMANDS_REGISTERED = True + install_menu_action() + + +register_commands() diff --git a/tests/python/freecad_exchange_template_authoring_panel_test.py b/tests/python/freecad_exchange_template_authoring_panel_test.py new file mode 100644 index 0000000..67933a7 --- /dev/null +++ b/tests/python/freecad_exchange_template_authoring_panel_test.py @@ -0,0 +1,85 @@ +import importlib +import sys +import types +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +MODULE_DIR = REPO_ROOT / "src" / "Mod" / "FreeCADExchange" +if str(MODULE_DIR) not in sys.path: + sys.path.insert(0, str(MODULE_DIR)) + + +def _install_fake_modules(): + fake_freecad = types.ModuleType("FreeCAD") + fake_freecad.ActiveDocument = None + fake_freecad.Console = types.SimpleNamespace( + PrintMessage=lambda *args, **kwargs: None, + PrintWarning=lambda *args, **kwargs: None, + PrintError=lambda *args, **kwargs: None, + ) + sys.modules["FreeCAD"] = fake_freecad + + fake_freecadgui = types.ModuleType("FreeCADGui") + fake_freecadgui.addCommand = lambda *args, **kwargs: None + fake_freecadgui.Control = types.SimpleNamespace( + activeDialog=lambda: False, + showDialog=lambda panel: panel, + closeDialog=lambda: None, + ) + sys.modules["FreeCADGui"] = fake_freecadgui + + fake_template_authoring = types.ModuleType("TemplateAuthoring") + fake_template_authoring.validate_template_terminals = lambda doc: { + "terminals": [], + "total_terminals": 0, + "valid_terminals": 0, + "warnings": [], + } + fake_template_authoring._selection_position = lambda: None + fake_template_authoring.create_template_terminal = lambda *args, **kwargs: None + fake_template_authoring.save_template_as_fcstd = lambda *args, **kwargs: {} + sys.modules["TemplateAuthoring"] = fake_template_authoring + + +def _reload_panel_module(): + sys.modules.pop("TemplateAuthoringPanel", None) + return importlib.import_module("TemplateAuthoringPanel") + + +class TemplateAuthoringPanelTest(unittest.TestCase): + def test_next_slot_name_uses_next_terminal_number(self): + _install_fake_modules() + panel_module = _reload_panel_module() + + self.assertEqual( + "T3", + panel_module.next_slot_name( + { + "terminals": [ + {"slot_name": "P1"}, + {"slot_name": "P2"}, + ] + } + ), + ) + + def test_terminal_list_text_marks_invalid_terminal(self): + _install_fake_modules() + panel_module = _reload_panel_module() + + rows = panel_module.terminal_list_text( + { + "terminals": [ + {"name": "Terminal_P1", "slot_name": "P1", "can_wire": True}, + {"name": "Broken", "slot_name": "", "can_wire": False}, + ] + } + ) + + self.assertEqual(["P1 - Terminal_P1", "(unnamed) - Broken [invalid]"], rows) + + +if __name__ == "__main__": + unittest.main()