From 5fc2b9b04aab696cebe790dc7d5a668346a7661b Mon Sep 17 00:00:00 2001 From: Zhaowenlong Date: Wed, 20 May 2026 19:01:24 +0800 Subject: [PATCH] =?UTF-8?q?feature/FCStd=E8=AE=BE=E5=A4=87=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E7=AB=AF=E5=AD=90=E5=88=B6=E4=BD=9C-zwl-0520?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/FreeCAD 二次开发说明.md | 86 +++ ...子显示连线保存回写开发文档.md | 105 +++- ...-05-20-freecad-fcstd-template-authoring.md | 590 ++++++++++++++++++ ...freecad-fcstd-template-authoring-design.md | 189 ++++++ src/Mod/FreeCADExchange/CMakeLists.txt | 1 + src/Mod/FreeCADExchange/InitGui.py | 6 + src/Mod/FreeCADExchange/TemplateAuthoring.py | 253 ++++++++ ...reecad_exchange_template_authoring_test.py | 163 +++++ 8 files changed, 1386 insertions(+), 7 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-20-freecad-fcstd-template-authoring.md create mode 100644 docs/superpowers/specs/2026-05-20-freecad-fcstd-template-authoring-design.md create mode 100644 src/Mod/FreeCADExchange/TemplateAuthoring.py create mode 100644 tests/python/freecad_exchange_template_authoring_test.py diff --git a/docs/FreeCAD 二次开发说明.md b/docs/FreeCAD 二次开发说明.md index 0108a5e..72656f6 100644 --- a/docs/FreeCAD 二次开发说明.md +++ b/docs/FreeCAD 二次开发说明.md @@ -243,6 +243,31 @@ FreeCAD 原生能力足够支持下面这些事: 这个方案不要求现在就修改 FreeCAD 原生源码,但已经足够支撑后续做一个结构清晰、规则明确的电气端子与 3D 接线系统。 +### 13.1 FCStd 设备模板作为正式资产 + +对于从厂家、网络或已有资源库拿到的 `.step`、`.stp`、`.ste` 模型,不建议把它们直接当作最终电气设备模板。原因是 STEP 系列文件主要表达几何,不能可靠保存 FreeCAD LCS、动态属性和二次开发语义。 + +当前推荐的正式资产流程是: + +```text +STEP / STP / STE 几何模型 + -> FreeCAD 模板制作 + -> 添加 LCS 端子 + -> 写入端子槽位语义 + -> 保存为 FCStd 设备模板 +``` + +这样得到的 `.FCStd` 才是后续工程复用、交付给其他人、放入设备资源库的主文件。 + +模板端子是“通用槽位”,不是“某个工程中的端子实例”。例如电流互感器模板中可以保存: + +```text +Terminal_P1 +Terminal_P2 +``` + +这些对象表示模型上 P1/P2 的真实接线位置和方向,但不保存某个工程里的 `terminal_uuid`。项目导入时,FreeCADExchange 再根据 `2d_to_3d.json` 把工程端子 UUID 绑定到模板槽位上。 + ## 14. 电气柜与设备装配 除了端子与接线,电气柜场景通常还会遇到另一个基础问题: @@ -630,6 +655,61 @@ LCS 不负责: 电气语义仍建议放在端子对象本身的属性里。 +### 21.3 模板端子和工程端子的区别 + +后续 FreeCAD 二次开发中需要区分两类端子: + +1. 模板端子 +2. 工程端子 + +模板端子存在于设备模板 `.FCStd` 中,职责是描述“这个设备模型上哪里可以接线”。它只保存跨工程稳定的信息: + +- `Role = "Terminal"` +- `CanWire = true` +- `QetTemplateSlotName` +- `QetTerminalLabel` +- `QetTerminalType` +- LCS 的位置和方向 + +模板端子不保存: + +- `project_uuid` +- `element_uuid` +- `terminal_uuid` +- `instance_id` +- 数据库绑定字段 + +工程端子存在于具体项目的 `scene.FCStd` 中,职责是描述“当前工程里的哪个 2D 端子绑定到了哪个 3D 连接点”。它由 FreeCADExchange 根据 `2d_to_3d.json` 生成或更新,才会保存: + +- `QetProjectUuid` +- `QetElementUuid` +- `QetTerminalUuid` +- `QetInstanceId` +- `CanWire` + +因此,FCStd 设备模板是可复用的电气几何资产,项目场景 FCStd 是某个工程的装配和接线结果。不要把两者的职责混在同一个对象里。 + +### 21.4 模板制作工具方向 + +为了让普通 STEP 模型变成可复用电气模板,建议在 `FreeCADExchange` Python 层增加模板制作工具,而不是修改 FreeCAD C++ 内核。 + +第一版工具目标: + +1. 导入 STEP / STP / STE 几何模型。 +2. 用户选择模型上的接线位置。 +3. 输入端子槽位名,例如 `P1`、`P2`、`A1`、`A2`。 +4. 自动创建 LCS。 +5. 自动写入模板端子属性。 +6. 保存为 `.FCStd`。 + +第一版建议新增模块: + +```text +src/Mod/FreeCADExchange/TemplateAuthoring.py +``` + +该模块只负责设备模板制作,不负责项目导入、手动连线或数据库回写。 + ## 22. 3D 端子二开实施步骤 ### 阶段 1:读绑定,不做自动路由 @@ -647,6 +727,12 @@ LCS 不负责: - 2D 设备能不能在 3D 找到对应设备 - 2D 端子能不能在 3D 找到对应连接点 +补充说明: + +- 项目端子创建前,优先读取 FCStd 设备模板中的 `Role="Terminal"` LCS。 +- 如果模板没有端子语义,才使用 sidecar 或 bbox fallback。 +- 长期目标是让常用设备都具备 FCStd 模板端子,而不是长期依赖 fallback。 + ### 阶段 2:让 3D 连线只认端子对象 下一步完成: diff --git a/docs/FreeCAD 端子显示连线保存回写开发文档.md b/docs/FreeCAD 端子显示连线保存回写开发文档.md index 00a7460..863d6a3 100644 --- a/docs/FreeCAD 端子显示连线保存回写开发文档.md +++ b/docs/FreeCAD 端子显示连线保存回写开发文档.md @@ -113,16 +113,28 @@ QET 导出的 `device_models[].resolved_model_path` 本来就是给 FreeCAD 使 - `.brp` - `.fcstd` -因此本地 STEP 文件可以直接作为第一批模板资源。 +因此本地 STEP 文件可以直接作为第一批几何资源导入。 + +但从当前开发方向开始,正式可复用的设备模板建议统一保存为 `.FCStd`。也就是说: + +```text +STEP / STP / STE 原始几何 + -> 在 FreeCAD 里添加端子 LCS 和电气语义 + -> 保存为 FCStd 设备模板 + -> 后续不同工程、不同人员统一使用这个 FCStd +``` + +STEP / STP / STE 适合作为模板制作的输入,不建议作为长期带电气语义的最终交付文件。因为 STEP 可以稳定保存几何,但不能可靠保存 FreeCAD LCS、动态属性、端子角色、接线资格等二次开发语义。 但需要注意: > 本地 STEP 只提供几何,不天然提供“哪个位置是端子”。 -所以要实现端子显示和端子连线,本地模板还必须补一层端子语义。第一版可以采用两种方式: +所以要实现端子显示和端子连线,本地模板还必须补一层端子语义。第一版采用下面的优先级: -1. 优先方式:使用 FCStd 模板,在模板里提前放好 LCS 端子对象。 +1. 正式方式:使用 FCStd 模板,在模板里提前放好 LCS 端子对象。 2. 过渡方式:STEP + sidecar JSON,在同目录下保存端子槽位坐标。 +3. 验证方式:没有模板语义时,临时使用 bbox fallback 生成端子位置。 sidecar 只作为 FreeCAD 端模板辅助文件,不进入第一版数据库绑定主键。 @@ -130,6 +142,46 @@ sidecar 里除了端子坐标,还可以继续补端子朝向,例如 `rotatio FCStd 模板里的 LCS 如果已经带了 Placement 朝向,导入时也要一并保留,这样端子不只是有坐标,还能保留真实出线方向。 +### 3.1 FCStd 设备模板制作约定 + +FCStd 设备模板用于解决“这个模型本身就带端子语义”的问题。模板端子是跨工程复用的槽位,不绑定某个具体工程里的 `terminal_uuid`。 + +推荐模板结构: + +```text +电流互感器.FCStd + ModelGeometry + Terminal_P1 + Terminal_P2 +``` + +模板端子使用 LCS 表示,至少保存: + +- `Role = "Terminal"` +- `CanWire = true` +- `QetTemplateSlotName = "P1"` / `"P2"` +- `QetTerminalLabel = "P1"` / `"P2"` +- `QetTerminalType = "primary"` 等可选分类 + +模板端子不保存: + +- `QetTerminalUuid` +- `QetInstanceId` +- `project_uuid` +- 任意工程内绑定字段 + +这些工程级字段由导入项目时的 `2d_to_3d.json` 和 FreeCADExchange 运行时补齐。这样同一个 FCStd 设备模板才能在多个工程、多台机器、多人之间复用。 + +### 3.2 模板制作工具目标 + +后续在 `FreeCADExchange` 中新增模板制作能力,目标是让用户不需要手工给 LCS 添加属性: + +1. 导入 STEP / STP / STE 几何模型。 +2. 在模型真实接线位置添加端子,例如 `P1`、`P2`。 +3. 自动创建 LCS,并写入模板端子语义。 +4. 保存为 `.FCStd` 设备模板。 +5. 后续 LightWork3D 工程引用该 `.FCStd` 后,自动识别模板端子并生成工程端子对象。 + ## 4. 为什么要先落地设备模板 这里的“设备模板”不是要求先把所有设备都建完,而是要先有一个稳定样板,证明端子显示和连线可以依附在真实设备上。 @@ -221,6 +273,7 @@ FreeCADExchange/ ExchangeBootstrap.py # 启动入口,读取 JSON,调度导入流程 DeviceImport.py # 设备导入,已存在 TemplateSemantics.py # 新增:读取 FCStd LCS 或 STEP sidecar 端子槽位 + TemplateAuthoring.py # 计划新增:把 STEP/STP/STE 制作为带端子语义的 FCStd 模板 TerminalImport.py # 新增:根据 terminals 创建/更新端子对象 TerminalObjects.py # 新增:端子对象属性、查找、校验工具 ManualWiring.py # 新增:端子选择、折线路径创建、连线对象属性 @@ -249,11 +302,45 @@ ManualWiring.py ## 7. 开发步骤 +### 阶段 A0:FCStd 设备模板制作 + +目标: + +- 用户可以导入 STEP / STP / STE 几何模型。 +- 用户可以给模型添加带电气语义的端子 LCS。 +- 模板保存为 `.FCStd` 后,可以脱离当前工程复用。 + +实现建议: + +- 新增 `TemplateAuthoring.py`。 +- 提供最小命令: + - `QET_Template_AddTerminal` + - `QET_Template_SaveAsFCStd` +- 第一版端子位置可以通过用户选择对象/点位后的三维坐标生成。 +- 第一版端子方向默认使用单位旋转,后续再补出线方向编辑。 + +模板端子属性: + +- `Role = "Terminal"` +- `CanWire = true` +- `QetTemplateSlotName` +- `QetTerminalLabel` +- `QetTerminalType` + +验收: + +- 打开一个普通 STEP 模型。 +- 添加 `P1`、`P2` 两个端子。 +- 保存为 `电流互感器.FCStd`。 +- 重新打开该 FCStd 后,端子 LCS 仍存在,属性仍存在。 +- 在项目导入流程中引用该 FCStd,端子位置优先来自模板 LCS,而不是 bbox fallback。 + ### 阶段 A:本地模板导入基线 目标: - 本地 STEP / FCStd 可以稳定导入。 +- 正式设备资源优先使用带端子语义的 FCStd 模板。 - 设备组有 `QetElementUuid / QetInstanceId`。 - 重新打开同一个项目时,不重复创建同一设备。 @@ -265,7 +352,7 @@ ManualWiring.py 验收: -- 使用本地 STEP 文件导入设备。 +- 使用本地 FCStd 模板导入设备。 - FreeCAD 树中可看到设备组。 - 关闭并重新打开,不产生重复设备组。 @@ -405,11 +492,13 @@ ManualWiring.py 每个模板至少准备: -- 原始模型文件:`.STEP` 或 `.FCStd` -- 可选 sidecar:端子槽位坐标 +- 原始几何文件:`.STEP` / `.STP` / `.STE` +- 正式模板文件:`.FCStd` +- 模板内 LCS 端子:`Role="Terminal"`,带槽位名和接线资格 +- 可选 sidecar:只作为过渡或校验,不作为正式交付优先方案 - 模板说明:原点、朝向、尺寸单位、端子数量 -常用设备建议优先补齐 `sidecar JSON` 或 `FCStd LCS`,把端子位置从临时的 `bbox fallback` 提升为真实可用坐标。 +常用设备建议优先补齐 `FCStd LCS`,把端子位置从临时的 `bbox fallback` 提升为真实可用坐标。 ## 10. 单人开发优先级 @@ -458,4 +547,6 @@ ManualWiring.py - 2026-05-20:补上 sidecar `rotation` 解析与端子 Placement 应用,端子模板现在可以同时带位置和方向;已用单元测试验证解析和放置逻辑。 - 2026-05-20:补上 FCStd 模板 LCS 朝向保留,模板端子现在可以从源对象直接继承 Placement 方向;已用单元测试验证。 - 2026-05-20:补上手动连线对象归属到设备 `QETWires_*` 组,连线树结构现在和设备模板一致;已用单元测试验证。 +- 2026-05-20:明确 A 方案:STEP/STP/STE 只作为原始几何输入,正式可复用设备资源统一保存为带 LCS 电气端子的 FCStd 模板;后续设计 `TemplateAuthoring.py` 做模板制作工具。 +- 2026-05-20:新增 FCStd 设备模板制作基础能力,支持把模型上的点位创建为带 `Role="Terminal"`、`CanWire=true`、`QetTemplateSlotName` 的模板端子 LCS;已用单元测试验证端子语义写入和模板校验逻辑。 ``` diff --git a/docs/superpowers/plans/2026-05-20-freecad-fcstd-template-authoring.md b/docs/superpowers/plans/2026-05-20-freecad-fcstd-template-authoring.md new file mode 100644 index 0000000..3ce693d --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-freecad-fcstd-template-authoring.md @@ -0,0 +1,590 @@ +# FreeCAD FCStd Template Authoring Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a FreeCADExchange Python authoring tool that turns imported STEP/STP/STE geometry into reusable FCStd equipment templates with electrical terminal LCS semantics. + +**Architecture:** Keep the feature in the FreeCADExchange Python layer. `TemplateAuthoring.py` owns template terminal creation, validation, and command registration; `TerminalObjects.py` remains the shared low-level semantic property helper; `InitGui.py` registers authoring commands when FreeCAD starts. + +**Tech Stack:** FreeCAD Python API, `Part::LocalCoordinateSystem` / `PartDesign::CoordinateSystem`, Python `unittest`, existing FreeCADExchange command registration pattern. + +--- + +## File Structure + +- Create: `src/Mod/FreeCADExchange/TemplateAuthoring.py` + - Build template terminal LCS objects. + - Write template-only electrical semantics. + - Validate template terminal objects. + - Register FreeCAD commands. +- Modify: `src/Mod/FreeCADExchange/CMakeLists.txt` + - Add `TemplateAuthoring.py` to `FreeCADExchange_Scripts`. +- Modify: `src/Mod/FreeCADExchange/InitGui.py` + - Import `TemplateAuthoring`. + - Call `TemplateAuthoring.register_commands()`. +- Create: `tests/python/freecad_exchange_template_authoring_test.py` + - Fake enough FreeCAD API to test terminal creation and validation without launching FreeCAD. +- Modify: `docs/FreeCAD 端子显示连线保存回写开发文档.md` + - Add development result entry after implementation. + +--- + +### Task 1: Add Failing Tests For Template Terminal Creation + +**Files:** +- Create: `tests/python/freecad_exchange_template_authoring_test.py` + +- [ ] **Step 1: Write the failing test file** + +Create `tests/python/freecad_exchange_template_authoring_test.py` with: + +```python +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_freecad(): + class Vector: + def __init__(self, x=0.0, y=0.0, z=0.0): + self.x = float(x) + self.y = float(y) + self.z = float(z) + + class Rotation: + def __init__(self, axis=None, angle=None): + self.Axis = axis + self.Angle = angle + + class Placement: + def __init__(self, base=None, rotation=None): + self.Base = base + self.Rotation = rotation + + fake_freecad = types.ModuleType("FreeCAD") + fake_freecad.Vector = Vector + fake_freecad.Rotation = Rotation + fake_freecad.Placement = Placement + 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.Selection = types.SimpleNamespace(getSelectionEx=lambda: []) + sys.modules["FreeCADGui"] = fake_freecadgui + + +class FakeViewObject: + def __init__(self): + self.Visibility = True + self.ShapeColor = None + + +class FakeObject: + def __init__(self, name, type_id): + self.Name = name + self.Label = name + self.TypeId = type_id + self.PropertiesList = [] + self.Group = [] + self.ViewObject = FakeViewObject() + self.Placement = None + + def isDerivedFrom(self, type_name): + if self.TypeId == type_name: + return True + if type_name == "App::DocumentObjectGroup": + return self.TypeId == "App::DocumentObjectGroup" + if type_name == "App::LocalCoordinateSystem": + return self.TypeId in {"Part::LocalCoordinateSystem", "PartDesign::CoordinateSystem"} + return False + + def addProperty(self, prop_type, prop_name, group_name, description): + if prop_name not in self.PropertiesList: + self.PropertiesList.append(prop_name) + + def addObject(self, child): + if child not in self.Group: + self.Group.append(child) + + +class FakeDocument: + def __init__(self): + self.Name = "TemplateDoc" + self.Objects = [] + self.recomputed = False + + def addObject(self, type_name, name): + obj = FakeObject(name, type_name) + self.Objects.append(obj) + return obj + + def getObject(self, name): + for obj in self.Objects: + if obj.Name == name: + return obj + return None + + def recompute(self): + self.recomputed = True + + +def _reload_modules(): + for name in ["TerminalObjects", "TemplateAuthoring"]: + sys.modules.pop(name, None) + import TemplateAuthoring + return TemplateAuthoring + + +class TemplateAuthoringTest(unittest.TestCase): + def test_create_template_terminal_writes_lcs_semantics(self): + _install_fake_freecad() + template_authoring = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + + terminal = template_authoring.create_template_terminal( + doc, + "P1", + app.Vector(10, 20, 30), + terminal_type="primary", + ) + + self.assertEqual("Terminal_P1", terminal.Name) + self.assertEqual("P1", terminal.Label) + self.assertEqual("Terminal", terminal.Role) + self.assertTrue(terminal.CanWire) + self.assertEqual("P1", terminal.QetTemplateSlotName) + self.assertEqual("P1", terminal.QetTerminalLabel) + self.assertEqual("primary", terminal.QetTerminalType) + self.assertEqual(10.0, terminal.Placement.Base.x) + self.assertEqual(20.0, terminal.Placement.Base.y) + self.assertEqual(30.0, terminal.Placement.Base.z) + self.assertTrue(doc.recomputed) + + def test_validate_template_terminals_reports_missing_slot_name(self): + _install_fake_freecad() + template_authoring = _reload_modules() + doc = FakeDocument() + terminal = doc.addObject("Part::LocalCoordinateSystem", "BrokenTerminal") + terminal.addProperty("App::PropertyString", "Role", "QET Template", "role") + terminal.Role = "Terminal" + terminal.addProperty("App::PropertyBool", "CanWire", "QET Template", "can wire") + terminal.CanWire = True + + report = template_authoring.validate_template_terminals(doc) + + self.assertEqual(1, report["total_terminals"]) + self.assertEqual(0, report["valid_terminals"]) + self.assertEqual(1, len(report["warnings"])) + self.assertIn("QetTemplateSlotName", report["warnings"][0]) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the test and verify RED** + +Run: + +```powershell +python -m unittest tests.python.freecad_exchange_template_authoring_test -v +``` + +Expected: fails with `ModuleNotFoundError: No module named 'TemplateAuthoring'`. + +--- + +### Task 2: Implement TemplateAuthoring Core + +**Files:** +- Create: `src/Mod/FreeCADExchange/TemplateAuthoring.py` + +- [ ] **Step 1: Create the implementation** + +Create `src/Mod/FreeCADExchange/TemplateAuthoring.py` with: + +```python +# FreeCADExchange FCStd template authoring helpers. + +import FreeCAD as App + +try: + import FreeCADGui as Gui +except ImportError: + Gui = None + +import TerminalObjects + + +TEMPLATE_PROPERTY_GROUP = "QET Template" +DEFAULT_TERMINAL_TYPE = "generic" + + +class TemplateAuthoringError(RuntimeError): + pass + + +def _safe_slot_name(slot_name): + value = (slot_name or "").strip() + if not value: + raise TemplateAuthoringError("Terminal slot name is required.") + return value + + +def _terminal_object_name(slot_name): + return "Terminal_{0}".format(TerminalObjects.safe_token(slot_name)) + + +def _ensure_template_property(obj, prop_name, value, prop_type="App::PropertyString"): + if prop_type == "App::PropertyBool": + TerminalObjects.ensure_bool_property( + obj, + prop_name, + TEMPLATE_PROPERTY_GROUP, + "QET template terminal property", + bool(value), + ) + else: + TerminalObjects.ensure_string_property( + obj, + prop_name, + TEMPLATE_PROPERTY_GROUP, + "QET template terminal property", + value, + ) + + +def set_template_terminal_semantics(obj, slot_name, label="", terminal_type=DEFAULT_TERMINAL_TYPE): + slot_name = _safe_slot_name(slot_name) + label = (label or "").strip() or slot_name + terminal_type = (terminal_type or "").strip() or DEFAULT_TERMINAL_TYPE + + _ensure_template_property(obj, "Role", TerminalObjects.TERMINAL_ROLE) + _ensure_template_property(obj, "CanWire", True, prop_type="App::PropertyBool") + _ensure_template_property(obj, "QetTemplateSlotName", slot_name) + _ensure_template_property(obj, "QetTerminalLabel", label) + _ensure_template_property(obj, "QetTerminalType", terminal_type) + obj.Label = label + return obj + + +def create_template_terminal(doc, slot_name, position, rotation=None, label="", terminal_type=DEFAULT_TERMINAL_TYPE): + if doc is None: + raise TemplateAuthoringError("An active FreeCAD document is required.") + + slot_name = _safe_slot_name(slot_name) + if position is None: + raise TemplateAuthoringError("A terminal position is required.") + + if rotation is None: + rotation = App.Rotation() + placement = App.Placement(position, rotation) + terminal = TerminalObjects.create_lcs_object( + doc, + _terminal_object_name(slot_name), + placement=placement, + label=(label or slot_name), + ) + set_template_terminal_semantics( + terminal, + slot_name, + label=label or slot_name, + terminal_type=terminal_type, + ) + try: + terminal.ViewObject.ShapeColor = (0.0, 0.75, 1.0) + except Exception: + pass + doc.recompute() + return terminal + + +def is_template_terminal(obj): + if obj is None: + return False + return TerminalObjects.is_terminal_hint_object(obj) + + +def _has_property(obj, prop_name): + return prop_name in getattr(obj, "PropertiesList", []) + + +def validate_template_terminals(doc): + report = { + "document_name": getattr(doc, "Name", ""), + "total_terminals": 0, + "valid_terminals": 0, + "warnings": [], + "terminals": [], + } + if doc is None: + report["warnings"].append("No active FreeCAD document.") + return report + + for obj in list(getattr(doc, "Objects", []) or []): + if not is_template_terminal(obj): + continue + + report["total_terminals"] += 1 + slot_name = getattr(obj, "QetTemplateSlotName", "").strip() + can_wire = bool(getattr(obj, "CanWire", False)) + item = { + "name": getattr(obj, "Name", ""), + "label": getattr(obj, "Label", ""), + "slot_name": slot_name, + "can_wire": can_wire, + } + report["terminals"].append(item) + + valid = True + if not _has_property(obj, "QetTemplateSlotName") or not slot_name: + report["warnings"].append( + "Template terminal {0} is missing QetTemplateSlotName.".format( + getattr(obj, "Name", "") + ) + ) + valid = False + if not can_wire: + report["warnings"].append( + "Template terminal {0} has CanWire disabled.".format( + getattr(obj, "Name", "") + ) + ) + valid = False + if valid: + report["valid_terminals"] += 1 + + return report + + +def _selection_position(): + if Gui is None: + return None + try: + selection_ex = Gui.Selection.getSelectionEx() + except Exception: + return None + if not selection_ex: + return None + + picked = selection_ex[0] + picked_points = list(getattr(picked, "PickedPoints", []) or []) + if picked_points: + return picked_points[0] + + obj = getattr(picked, "Object", None) + shape = getattr(obj, "Shape", None) + bound_box = getattr(shape, "BoundBox", None) + if bound_box is None: + return None + return App.Vector( + (bound_box.XMin + bound_box.XMax) * 0.5, + (bound_box.YMin + bound_box.YMax) * 0.5, + (bound_box.ZMin + bound_box.ZMax) * 0.5, + ) +``` + +- [ ] **Step 2: Run the focused test and verify GREEN** + +Run: + +```powershell +python -m unittest tests.python.freecad_exchange_template_authoring_test -v +``` + +Expected: both tests pass. + +--- + +### Task 3: Add FreeCAD Commands And Registration + +**Files:** +- Modify: `src/Mod/FreeCADExchange/TemplateAuthoring.py` +- Modify: `src/Mod/FreeCADExchange/InitGui.py` +- Modify: `src/Mod/FreeCADExchange/CMakeLists.txt` + +- [ ] **Step 1: Extend TemplateAuthoring.py with commands** + +Append this code to `src/Mod/FreeCADExchange/TemplateAuthoring.py`: + +```python +class CommandAddTemplateTerminal: + def GetResources(self): + return { + "MenuText": "Add Template Terminal", + "ToolTip": "Create a reusable electrical terminal LCS for an FCStd equipment template", + } + + def IsActive(self): + return App.ActiveDocument is not None and Gui is not None + + def Activated(self): + if Gui is None: + return + position = _selection_position() + if position is None: + App.Console.PrintWarning( + "Select a model point or model object before adding a template terminal.\n" + ) + return + + slot_name = "T{0}".format(len(validate_template_terminals(App.ActiveDocument)["terminals"]) + 1) + try: + create_template_terminal(App.ActiveDocument, slot_name, position) + App.Console.PrintMessage( + "[FreeCADExchange] Created template terminal {0}. Rename QetTemplateSlotName if needed.\n".format(slot_name) + ) + except Exception as exc: + App.Console.PrintError( + "[FreeCADExchange] template terminal creation failed: {0}\n".format(exc) + ) + + +class CommandValidateTemplateTerminals: + def GetResources(self): + return { + "MenuText": "Validate Template Terminals", + "ToolTip": "Validate electrical terminal LCS objects in the current FCStd template", + } + + def IsActive(self): + return App.ActiveDocument is not None + + def Activated(self): + report = validate_template_terminals(App.ActiveDocument) + App.Console.PrintMessage( + "[FreeCADExchange] Template terminals: {0} total, {1} valid\n".format( + report["total_terminals"], + report["valid_terminals"], + ) + ) + for warning in report["warnings"]: + App.Console.PrintWarning("[FreeCADExchange] {0}\n".format(warning)) + + +_COMMANDS_REGISTERED = False + + +def register_commands(): + global _COMMANDS_REGISTERED + if _COMMANDS_REGISTERED: + return + if Gui is None: + return + Gui.addCommand("QET_Template_AddTerminal", CommandAddTemplateTerminal()) + Gui.addCommand("QET_Template_ValidateTerminals", CommandValidateTemplateTerminals()) + _COMMANDS_REGISTERED = True + + +register_commands() +``` + +- [ ] **Step 2: Modify InitGui.py** + +Add the import near the existing FreeCADExchange imports: + +```python +import TemplateAuthoring +``` + +Add registration after the existing command registration blocks: + +```python +try: + TemplateAuthoring.register_commands() +except Exception: + pass +``` + +- [ ] **Step 3: Modify CMakeLists.txt** + +Add `TemplateAuthoring.py` to `FreeCADExchange_Scripts`: + +```cmake + TemplateAuthoring.py +``` + +Place it beside `TemplateSemantics.py`. + +- [ ] **Step 4: Verify syntax and tests** + +Run: + +```powershell +python -m py_compile src/Mod/FreeCADExchange/TemplateAuthoring.py src/Mod/FreeCADExchange/InitGui.py +python -m unittest tests.python.freecad_exchange_template_authoring_test -v +``` + +Expected: py_compile exits 0, tests pass. + +--- + +### Task 4: Add Development Result Documentation + +**Files:** +- Modify: `docs/FreeCAD 端子显示连线保存回写开发文档.md` + +- [ ] **Step 1: Add the result entry** + +Append to the development log section: + +```markdown +- 2026-05-20:新增 FCStd 设备模板制作基础能力,支持把模型上的点位创建为带 `Role="Terminal"`、`CanWire=true`、`QetTemplateSlotName` 的模板端子 LCS;已用单元测试验证端子语义写入和模板校验逻辑。 +``` + +- [ ] **Step 2: Run final verification** + +Run: + +```powershell +python -m py_compile src/Mod/FreeCADExchange/TemplateAuthoring.py src/Mod/FreeCADExchange/InitGui.py src/Mod/FreeCADExchange/TerminalObjects.py +python -m unittest tests.python.freecad_exchange_template_authoring_test tests.python.freecad_exchange_template_semantics_test tests.python.freecad_exchange_manual_wiring_test -v +``` + +Expected: all tests pass. + +- [ ] **Step 3: Commit** + +Run: + +```powershell +git add src/Mod/FreeCADExchange/TemplateAuthoring.py src/Mod/FreeCADExchange/InitGui.py src/Mod/FreeCADExchange/CMakeLists.txt tests/python/freecad_exchange_template_authoring_test.py "docs/FreeCAD 端子显示连线保存回写开发文档.md" "docs/FreeCAD 二次开发说明.md" docs/superpowers/specs/2026-05-20-freecad-fcstd-template-authoring-design.md docs/superpowers/plans/2026-05-20-freecad-fcstd-template-authoring.md +git commit -m "feature/FCStd设备模板端子制作-zwl-0520" +``` + +Expected: commit succeeds and does not include `artifacts/`, `video_frames/`, or generated cache files. + +--- + +## Self-Review + +Spec coverage: + +- FCStd as formal asset is covered by Tasks 2 and 4. +- Template-only terminal semantics are covered by Task 1 and Task 2. +- Command entry points are covered by Task 3. +- Existing project import flow remains unchanged. + +Placeholder scan: + +- The plan contains no unresolved placeholders or open-ended implementation steps. + +Type consistency: + +- The planned module exposes `create_template_terminal`, `set_template_terminal_semantics`, `validate_template_terminals`, and `register_commands`. +- Command names are `QET_Template_AddTerminal` and `QET_Template_ValidateTerminals`. diff --git a/docs/superpowers/specs/2026-05-20-freecad-fcstd-template-authoring-design.md b/docs/superpowers/specs/2026-05-20-freecad-fcstd-template-authoring-design.md new file mode 100644 index 0000000..2e2751a --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-freecad-fcstd-template-authoring-design.md @@ -0,0 +1,189 @@ +# FreeCAD FCStd 电气设备模板制作设计 + +## 1. 目标 + +把普通 STEP / STP / STE 几何模型加工成可复用的 `.FCStd` 电气设备模板。 + +模板保存设备几何和端子槽位语义。后续不同工程、不同人员导入这个 FCStd 时,FreeCADExchange 能识别其中的端子 LCS,并把工程里的真实 `terminal_uuid` 绑定到这些槽位上。 + +## 2. 非目标 + +- 不修改 FreeCAD C/C++ 内核。 +- 不在模板文件里保存工程级 `terminal_uuid`、`project_uuid`、`instance_id`。 +- 不做自动布线。 +- 不把完整接线路径写入数据库。 +- 不要求第一版做完整 UI 面板。 + +## 3. 核心原则 + +STEP / STP / STE 是几何输入,FCStd 是正式设备模板输出。 + +模板端子描述“这个设备模型哪里可以接线”。工程端子描述“当前工程的哪个 2D 端子绑定到哪个 3D 端子对象”。两者不能混用。 + +## 4. 模板文件结构 + +以电流互感器为例: + +```text +电流互感器.FCStd + ModelGeometry + Terminal_P1 + Terminal_P2 +``` + +`Terminal_P1` 和 `Terminal_P2` 是 LCS 对象。它们的位置就是模型上真实接线位置,方向就是后续出线方向参考。 + +## 5. 模板端子属性 + +模板端子至少写入: + +```text +Role = Terminal +CanWire = true +QetTemplateSlotName = P1 +QetTerminalLabel = P1 +QetTerminalType = primary +``` + +模板端子不写入: + +```text +QetProjectUuid +QetElementUuid +QetTerminalUuid +QetInstanceId +``` + +这些字段只属于具体工程场景。 + +## 6. 新模块 + +新增模块: + +```text +src/Mod/FreeCADExchange/TemplateAuthoring.py +``` + +职责: + +- 创建模板端子 LCS。 +- 给 LCS 写入模板语义属性。 +- 校验当前文档中的模板端子。 +- 保存或辅助保存 `.FCStd` 模板。 + +不负责: + +- 工程 `2d_to_3d.json` 导入。 +- 工程端子 UUID 绑定。 +- 手动连线。 +- `3d_to_2d.json` 回写。 + +## 7. 第一版命令 + +第一版先做两个命令: + +```text +QET_Template_AddTerminal +QET_Template_ValidateTerminals +``` + +`QET_Template_AddTerminal`: + +1. 用户选择一个模型对象或模型上的点位。 +2. 执行命令。 +3. 输入端子槽位名,例如 `P1`。 +4. 在选择位置创建 LCS。 +5. 自动写入模板端子属性。 + +`QET_Template_ValidateTerminals`: + +1. 扫描当前文档中的端子 LCS。 +2. 检查是否有 `Role="Terminal"`。 +3. 检查是否有 `QetTemplateSlotName`。 +4. 输出端子列表和问题。 + +保存动作第一版直接使用 FreeCAD 自带保存为 `.FCStd`。后续再补 `QET_Template_SaveAsFCStd`。 + +## 8. 第一版点位策略 + +第一版按简单稳定策略实现: + +1. 如果用户选择对象的子元素,并且能取到选择点,就使用该点。 +2. 如果只能选到对象,则使用对象包围盒中心。 +3. 如果没有选择,则拒绝创建端子。 + +端子方向第一版使用默认旋转。后续再增加方向编辑,例如沿面法向、沿用户选择边方向或手动输入轴角。 + +## 9. 与现有导入流程的关系 + +现有项目导入流程保持不变: + +```text +ExchangeBootstrap + -> DeviceImport + -> TerminalImport + -> ExchangeWriteBack +``` + +`TerminalImport` 已经支持读取 FCStd 模板中的 `Role="Terminal"` LCS。模板制作工具只负责把这些 LCS 方便、规范地放进 FCStd。 + +项目导入时的优先级仍是: + +1. FCStd 模板 LCS。 +2. sidecar JSON。 +3. bbox fallback。 + +## 10. 用户流程 + +以电流互感器为例: + +1. 打开 FreeCAD。 +2. 导入 `电流互感器.step`。 +3. 选择 P1 接线位置。 +4. 执行 `QET_Template_AddTerminal`,输入 `P1`。 +5. 选择 P2 接线位置。 +6. 执行 `QET_Template_AddTerminal`,输入 `P2`。 +7. 执行 `QET_Template_ValidateTerminals`。 +8. 保存为 `电流互感器.FCStd`。 +9. 在 LightWork3D 设备 3D 资源中使用该 FCStd。 + +## 11. 验收标准 + +- 能从普通 STEP 模型创建 `Terminal_P1` 和 `Terminal_P2`。 +- 端子对象是 LCS。 +- 端子对象带 `Role="Terminal"` 和 `CanWire=true`。 +- 端子对象带 `QetTemplateSlotName`。 +- 保存为 FCStd 后重新打开,端子和属性仍存在。 +- 使用该 FCStd 作为工程模型时,端子位置来自模板 LCS,不再使用 bbox fallback。 + +## 12. 风险和处理 + +选择点不可用: + +- 第一版退回对象包围盒中心。 +- 如果连对象也没有选择,则直接提示错误。 + +端子重名: + +- 第一版自动使用唯一对象名,例如 `Terminal_P1_1`。 +- `QetTemplateSlotName` 保持用户输入,用于槽位语义。 + +模板里混入工程端子属性: + +- 校验命令提示警告。 +- 第一版不自动删除,避免误删用户数据。 + +方向不准确: + +- 第一版只保证位置和语义。 +- 后续补方向编辑命令。 + +## 13. 实施顺序 + +1. 新增 `TemplateAuthoring.py`。 +2. 在 `CMakeLists.txt` 加入该模块。 +3. 在 `InitGui.py` 注册模板命令。 +4. 实现添加端子命令。 +5. 实现校验命令。 +6. 增加 Python 单元测试,覆盖属性写入和校验逻辑。 +7. 用电流互感器模型手工验证一次完整流程。 diff --git a/src/Mod/FreeCADExchange/CMakeLists.txt b/src/Mod/FreeCADExchange/CMakeLists.txt index 93c3e7b..77254a2 100644 --- a/src/Mod/FreeCADExchange/CMakeLists.txt +++ b/src/Mod/FreeCADExchange/CMakeLists.txt @@ -7,6 +7,7 @@ set(FreeCADExchange_Scripts DevicePreview.py TerminalObjects.py TemplateSemantics.py + TemplateAuthoring.py TerminalImport.py ExchangeWriteBack.py ManualWiring.py diff --git a/src/Mod/FreeCADExchange/InitGui.py b/src/Mod/FreeCADExchange/InitGui.py index 83530ef..09fe20c 100644 --- a/src/Mod/FreeCADExchange/InitGui.py +++ b/src/Mod/FreeCADExchange/InitGui.py @@ -14,6 +14,7 @@ except ImportError: import ExchangeBootstrap import ExchangeWriteBack import ManualWiring +import TemplateAuthoring def _append_init_log(message): @@ -47,5 +48,10 @@ try: except Exception: pass +try: + TemplateAuthoring.register_commands() +except Exception: + pass + QtCore.QTimer.singleShot(0, ExchangeBootstrap.bootstrap_if_requested) diff --git a/src/Mod/FreeCADExchange/TemplateAuthoring.py b/src/Mod/FreeCADExchange/TemplateAuthoring.py new file mode 100644 index 0000000..1abd26c --- /dev/null +++ b/src/Mod/FreeCADExchange/TemplateAuthoring.py @@ -0,0 +1,253 @@ +# FreeCADExchange FCStd template authoring helpers. + +import FreeCAD as App + +try: + import FreeCADGui as Gui +except ImportError: + Gui = None + +import TerminalObjects + + +TEMPLATE_PROPERTY_GROUP = "QET Template" +DEFAULT_TERMINAL_TYPE = "generic" + + +class TemplateAuthoringError(RuntimeError): + pass + + +def _safe_slot_name(slot_name): + value = (slot_name or "").strip() + if not value: + raise TemplateAuthoringError("Terminal slot name is required.") + return value + + +def _terminal_object_name(slot_name): + return "Terminal_{0}".format(TerminalObjects.safe_token(slot_name)) + + +def _ensure_template_property(obj, prop_name, value, prop_type="App::PropertyString"): + if prop_type == "App::PropertyBool": + TerminalObjects.ensure_bool_property( + obj, + prop_name, + TEMPLATE_PROPERTY_GROUP, + "QET template terminal property", + bool(value), + ) + else: + TerminalObjects.ensure_string_property( + obj, + prop_name, + TEMPLATE_PROPERTY_GROUP, + "QET template terminal property", + value, + ) + + +def set_template_terminal_semantics(obj, slot_name, label="", terminal_type=DEFAULT_TERMINAL_TYPE): + slot_name = _safe_slot_name(slot_name) + label = (label or "").strip() or slot_name + terminal_type = (terminal_type or "").strip() or DEFAULT_TERMINAL_TYPE + + _ensure_template_property(obj, "Role", TerminalObjects.TERMINAL_ROLE) + _ensure_template_property(obj, "CanWire", True, prop_type="App::PropertyBool") + _ensure_template_property(obj, "QetTemplateSlotName", slot_name) + _ensure_template_property(obj, "QetTerminalLabel", label) + _ensure_template_property(obj, "QetTerminalType", terminal_type) + obj.Label = label + return obj + + +def create_template_terminal(doc, slot_name, position, rotation=None, label="", terminal_type=DEFAULT_TERMINAL_TYPE): + if doc is None: + raise TemplateAuthoringError("An active FreeCAD document is required.") + + slot_name = _safe_slot_name(slot_name) + if position is None: + raise TemplateAuthoringError("A terminal position is required.") + + if rotation is None: + rotation = App.Rotation() + placement = App.Placement(position, rotation) + terminal = TerminalObjects.create_lcs_object( + doc, + _terminal_object_name(slot_name), + placement=placement, + label=(label or slot_name), + ) + set_template_terminal_semantics( + terminal, + slot_name, + label=label or slot_name, + terminal_type=terminal_type, + ) + try: + terminal.ViewObject.ShapeColor = (0.0, 0.75, 1.0) + except Exception: + pass + doc.recompute() + return terminal + + +def is_template_terminal(obj): + if obj is None: + return False + return TerminalObjects.is_terminal_hint_object(obj) + + +def _has_property(obj, prop_name): + return prop_name in getattr(obj, "PropertiesList", []) + + +def validate_template_terminals(doc): + report = { + "document_name": getattr(doc, "Name", ""), + "total_terminals": 0, + "valid_terminals": 0, + "warnings": [], + "terminals": [], + } + if doc is None: + report["warnings"].append("No active FreeCAD document.") + return report + + for obj in list(getattr(doc, "Objects", []) or []): + if not is_template_terminal(obj): + continue + + report["total_terminals"] += 1 + slot_name = getattr(obj, "QetTemplateSlotName", "").strip() + can_wire = bool(getattr(obj, "CanWire", False)) + item = { + "name": getattr(obj, "Name", ""), + "label": getattr(obj, "Label", ""), + "slot_name": slot_name, + "can_wire": can_wire, + } + report["terminals"].append(item) + + valid = True + if not _has_property(obj, "QetTemplateSlotName") or not slot_name: + report["warnings"].append( + "Template terminal {0} is missing QetTemplateSlotName.".format( + getattr(obj, "Name", "") + ) + ) + valid = False + if not can_wire: + report["warnings"].append( + "Template terminal {0} has CanWire disabled.".format( + getattr(obj, "Name", "") + ) + ) + valid = False + if valid: + report["valid_terminals"] += 1 + + return report + + +def _selection_position(): + if Gui is None: + return None + try: + selection_ex = Gui.Selection.getSelectionEx() + except Exception: + return None + if not selection_ex: + return None + + picked = selection_ex[0] + picked_points = list(getattr(picked, "PickedPoints", []) or []) + if picked_points: + return picked_points[0] + + obj = getattr(picked, "Object", None) + shape = getattr(obj, "Shape", None) + bound_box = getattr(shape, "BoundBox", None) + if bound_box is None: + return None + return App.Vector( + (bound_box.XMin + bound_box.XMax) * 0.5, + (bound_box.YMin + bound_box.YMax) * 0.5, + (bound_box.ZMin + bound_box.ZMax) * 0.5, + ) + + +class CommandAddTemplateTerminal: + def GetResources(self): + return { + "MenuText": "Add Template Terminal", + "ToolTip": "Create a reusable electrical terminal LCS for an FCStd equipment template", + } + + def IsActive(self): + return App.ActiveDocument is not None and Gui is not None + + def Activated(self): + if Gui is None: + return + position = _selection_position() + if position is None: + App.Console.PrintWarning( + "Select a model point or model object before adding a template terminal.\n" + ) + return + + slot_name = "T{0}".format( + len(validate_template_terminals(App.ActiveDocument)["terminals"]) + 1 + ) + try: + create_template_terminal(App.ActiveDocument, slot_name, position) + App.Console.PrintMessage( + "[FreeCADExchange] Created template terminal {0}. Rename QetTemplateSlotName if needed.\n".format( + slot_name + ) + ) + except Exception as exc: + App.Console.PrintError( + "[FreeCADExchange] template terminal creation failed: {0}\n".format(exc) + ) + + +class CommandValidateTemplateTerminals: + def GetResources(self): + return { + "MenuText": "Validate Template Terminals", + "ToolTip": "Validate electrical terminal LCS objects in the current FCStd template", + } + + def IsActive(self): + return App.ActiveDocument is not None + + def Activated(self): + report = validate_template_terminals(App.ActiveDocument) + App.Console.PrintMessage( + "[FreeCADExchange] Template terminals: {0} total, {1} valid\n".format( + report["total_terminals"], + report["valid_terminals"], + ) + ) + for warning in report["warnings"]: + App.Console.PrintWarning("[FreeCADExchange] {0}\n".format(warning)) + + +_COMMANDS_REGISTERED = False + + +def register_commands(): + global _COMMANDS_REGISTERED + if _COMMANDS_REGISTERED: + return + if Gui is None: + return + Gui.addCommand("QET_Template_AddTerminal", CommandAddTemplateTerminal()) + Gui.addCommand("QET_Template_ValidateTerminals", CommandValidateTemplateTerminals()) + _COMMANDS_REGISTERED = True + + +register_commands() diff --git a/tests/python/freecad_exchange_template_authoring_test.py b/tests/python/freecad_exchange_template_authoring_test.py new file mode 100644 index 0000000..bdb1722 --- /dev/null +++ b/tests/python/freecad_exchange_template_authoring_test.py @@ -0,0 +1,163 @@ +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_freecad(): + class Vector: + def __init__(self, x=0.0, y=0.0, z=0.0): + self.x = float(x) + self.y = float(y) + self.z = float(z) + + class Rotation: + def __init__(self, axis=None, angle=None): + self.Axis = axis + self.Angle = angle + + class Placement: + def __init__(self, base=None, rotation=None): + self.Base = base + self.Rotation = rotation + + fake_freecad = types.ModuleType("FreeCAD") + fake_freecad.Vector = Vector + fake_freecad.Rotation = Rotation + fake_freecad.Placement = Placement + 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.Selection = types.SimpleNamespace(getSelectionEx=lambda: []) + sys.modules["FreeCADGui"] = fake_freecadgui + + +class FakeViewObject: + def __init__(self): + self.Visibility = True + self.ShapeColor = None + + +class FakeObject: + def __init__(self, name, type_id): + self.Name = name + self.Label = name + self.TypeId = type_id + self.PropertiesList = [] + self.Group = [] + self.ViewObject = FakeViewObject() + self.Placement = None + + def isDerivedFrom(self, type_name): + if self.TypeId == type_name: + return True + if type_name == "App::DocumentObjectGroup": + return self.TypeId == "App::DocumentObjectGroup" + if type_name == "App::LocalCoordinateSystem": + return self.TypeId in { + "Part::LocalCoordinateSystem", + "PartDesign::CoordinateSystem", + } + return False + + def addProperty(self, prop_type, prop_name, group_name, description): + if prop_name not in self.PropertiesList: + self.PropertiesList.append(prop_name) + + def addObject(self, child): + if child not in self.Group: + self.Group.append(child) + + +class FakeDocument: + def __init__(self): + self.Name = "TemplateDoc" + self.Objects = [] + self.recomputed = False + + def addObject(self, type_name, name): + obj = FakeObject(name, type_name) + self.Objects.append(obj) + return obj + + def getObject(self, name): + for obj in self.Objects: + if obj.Name == name: + return obj + return None + + def recompute(self): + self.recomputed = True + + +def _reload_modules(): + for name in ["TerminalObjects", "TemplateAuthoring"]: + sys.modules.pop(name, None) + return importlib.import_module("TemplateAuthoring") + + +class TemplateAuthoringTest(unittest.TestCase): + def test_create_template_terminal_writes_lcs_semantics(self): + _install_fake_freecad() + template_authoring = _reload_modules() + app = sys.modules["FreeCAD"] + doc = FakeDocument() + + terminal = template_authoring.create_template_terminal( + doc, + "P1", + app.Vector(10, 20, 30), + terminal_type="primary", + ) + + self.assertEqual("Terminal_P1", terminal.Name) + self.assertEqual("P1", terminal.Label) + self.assertEqual("Terminal", terminal.Role) + self.assertTrue(terminal.CanWire) + self.assertEqual("P1", terminal.QetTemplateSlotName) + self.assertEqual("P1", terminal.QetTerminalLabel) + self.assertEqual("primary", terminal.QetTerminalType) + self.assertEqual(10.0, terminal.Placement.Base.x) + self.assertEqual(20.0, terminal.Placement.Base.y) + self.assertEqual(30.0, terminal.Placement.Base.z) + self.assertTrue(doc.recomputed) + + def test_validate_template_terminals_reports_missing_slot_name(self): + _install_fake_freecad() + template_authoring = _reload_modules() + doc = FakeDocument() + terminal = doc.addObject("Part::LocalCoordinateSystem", "BrokenTerminal") + terminal.addProperty("App::PropertyString", "Role", "QET Template", "role") + terminal.Role = "Terminal" + terminal.addProperty( + "App::PropertyBool", + "CanWire", + "QET Template", + "can wire", + ) + terminal.CanWire = True + + report = template_authoring.validate_template_terminals(doc) + + self.assertEqual(1, report["total_terminals"]) + self.assertEqual(0, report["valid_terminals"]) + self.assertEqual(1, len(report["warnings"])) + self.assertIn("QetTemplateSlotName", report["warnings"][0]) + + +if __name__ == "__main__": + unittest.main()