feature/FCStd设备模板端子制作-zwl-0520

dev
Zhaowenlong 7 days ago
parent 0866f0049c
commit 5fc2b9b04a

@ -243,6 +243,31 @@ FreeCAD 原生能力足够支持下面这些事:
这个方案不要求现在就修改 FreeCAD 原生源码,但已经足够支撑后续做一个结构清晰、规则明确的电气端子与 3D 接线系统。 这个方案不要求现在就修改 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. 电气柜与设备装配 ## 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 端子二开实施步骤 ## 22. 3D 端子二开实施步骤
### 阶段 1读绑定不做自动路由 ### 阶段 1读绑定不做自动路由
@ -647,6 +727,12 @@ LCS 不负责:
- 2D 设备能不能在 3D 找到对应设备 - 2D 设备能不能在 3D 找到对应设备
- 2D 端子能不能在 3D 找到对应连接点 - 2D 端子能不能在 3D 找到对应连接点
补充说明:
- 项目端子创建前,优先读取 FCStd 设备模板中的 `Role="Terminal"` LCS。
- 如果模板没有端子语义,才使用 sidecar 或 bbox fallback。
- 长期目标是让常用设备都具备 FCStd 模板端子,而不是长期依赖 fallback。
### 阶段 2让 3D 连线只认端子对象 ### 阶段 2让 3D 连线只认端子对象
下一步完成: 下一步完成:

@ -113,16 +113,28 @@ QET 导出的 `device_models[].resolved_model_path` 本来就是给 FreeCAD 使
- `.brp` - `.brp`
- `.fcstd` - `.fcstd`
因此本地 STEP 文件可以直接作为第一批模板资源。 因此本地 STEP 文件可以直接作为第一批几何资源导入。
但从当前开发方向开始,正式可复用的设备模板建议统一保存为 `.FCStd`。也就是说:
```text
STEP / STP / STE 原始几何
-> 在 FreeCAD 里添加端子 LCS 和电气语义
-> 保存为 FCStd 设备模板
-> 后续不同工程、不同人员统一使用这个 FCStd
```
STEP / STP / STE 适合作为模板制作的输入,不建议作为长期带电气语义的最终交付文件。因为 STEP 可以稳定保存几何,但不能可靠保存 FreeCAD LCS、动态属性、端子角色、接线资格等二次开发语义。
但需要注意: 但需要注意:
> 本地 STEP 只提供几何,不天然提供“哪个位置是端子”。 > 本地 STEP 只提供几何,不天然提供“哪个位置是端子”。
所以要实现端子显示和端子连线,本地模板还必须补一层端子语义。第一版可以采用两种方式: 所以要实现端子显示和端子连线,本地模板还必须补一层端子语义。第一版采用下面的优先级
1. 优先方式:使用 FCStd 模板,在模板里提前放好 LCS 端子对象。 1. 正式方式:使用 FCStd 模板,在模板里提前放好 LCS 端子对象。
2. 过渡方式STEP + sidecar JSON在同目录下保存端子槽位坐标。 2. 过渡方式STEP + sidecar JSON在同目录下保存端子槽位坐标。
3. 验证方式:没有模板语义时,临时使用 bbox fallback 生成端子位置。
sidecar 只作为 FreeCAD 端模板辅助文件,不进入第一版数据库绑定主键。 sidecar 只作为 FreeCAD 端模板辅助文件,不进入第一版数据库绑定主键。
@ -130,6 +142,46 @@ sidecar 里除了端子坐标,还可以继续补端子朝向,例如 `rotatio
FCStd 模板里的 LCS 如果已经带了 Placement 朝向,导入时也要一并保留,这样端子不只是有坐标,还能保留真实出线方向。 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. 为什么要先落地设备模板 ## 4. 为什么要先落地设备模板
这里的“设备模板”不是要求先把所有设备都建完,而是要先有一个稳定样板,证明端子显示和连线可以依附在真实设备上。 这里的“设备模板”不是要求先把所有设备都建完,而是要先有一个稳定样板,证明端子显示和连线可以依附在真实设备上。
@ -221,6 +273,7 @@ FreeCADExchange/
ExchangeBootstrap.py # 启动入口,读取 JSON调度导入流程 ExchangeBootstrap.py # 启动入口,读取 JSON调度导入流程
DeviceImport.py # 设备导入,已存在 DeviceImport.py # 设备导入,已存在
TemplateSemantics.py # 新增:读取 FCStd LCS 或 STEP sidecar 端子槽位 TemplateSemantics.py # 新增:读取 FCStd LCS 或 STEP sidecar 端子槽位
TemplateAuthoring.py # 计划新增:把 STEP/STP/STE 制作为带端子语义的 FCStd 模板
TerminalImport.py # 新增:根据 terminals 创建/更新端子对象 TerminalImport.py # 新增:根据 terminals 创建/更新端子对象
TerminalObjects.py # 新增:端子对象属性、查找、校验工具 TerminalObjects.py # 新增:端子对象属性、查找、校验工具
ManualWiring.py # 新增:端子选择、折线路径创建、连线对象属性 ManualWiring.py # 新增:端子选择、折线路径创建、连线对象属性
@ -249,11 +302,45 @@ ManualWiring.py
## 7. 开发步骤 ## 7. 开发步骤
### 阶段 A0FCStd 设备模板制作
目标:
- 用户可以导入 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本地模板导入基线 ### 阶段 A本地模板导入基线
目标: 目标:
- 本地 STEP / FCStd 可以稳定导入。 - 本地 STEP / FCStd 可以稳定导入。
- 正式设备资源优先使用带端子语义的 FCStd 模板。
- 设备组有 `QetElementUuid / QetInstanceId` - 设备组有 `QetElementUuid / QetInstanceId`
- 重新打开同一个项目时,不重复创建同一设备。 - 重新打开同一个项目时,不重复创建同一设备。
@ -265,7 +352,7 @@ ManualWiring.py
验收: 验收:
- 使用本地 STEP 文件导入设备。 - 使用本地 FCStd 模板导入设备。
- FreeCAD 树中可看到设备组。 - FreeCAD 树中可看到设备组。
- 关闭并重新打开,不产生重复设备组。 - 关闭并重新打开,不产生重复设备组。
@ -405,11 +492,13 @@ ManualWiring.py
每个模板至少准备: 每个模板至少准备:
- 原始模型文件:`.STEP` 或 `.FCStd` - 原始几何文件:`.STEP` / `.STP` / `.STE`
- 可选 sidecar端子槽位坐标 - 正式模板文件:`.FCStd`
- 模板内 LCS 端子:`Role="Terminal"`,带槽位名和接线资格
- 可选 sidecar只作为过渡或校验不作为正式交付优先方案
- 模板说明:原点、朝向、尺寸单位、端子数量 - 模板说明:原点、朝向、尺寸单位、端子数量
常用设备建议优先补齐 `sidecar JSON` 或 `FCStd LCS`,把端子位置从临时的 `bbox fallback` 提升为真实可用坐标。 常用设备建议优先补齐 `FCStd LCS`,把端子位置从临时的 `bbox fallback` 提升为真实可用坐标。
## 10. 单人开发优先级 ## 10. 单人开发优先级
@ -458,4 +547,6 @@ ManualWiring.py
- 2026-05-20补上 sidecar `rotation` 解析与端子 Placement 应用,端子模板现在可以同时带位置和方向;已用单元测试验证解析和放置逻辑。 - 2026-05-20补上 sidecar `rotation` 解析与端子 Placement 应用,端子模板现在可以同时带位置和方向;已用单元测试验证解析和放置逻辑。
- 2026-05-20补上 FCStd 模板 LCS 朝向保留,模板端子现在可以从源对象直接继承 Placement 方向;已用单元测试验证。 - 2026-05-20补上 FCStd 模板 LCS 朝向保留,模板端子现在可以从源对象直接继承 Placement 方向;已用单元测试验证。
- 2026-05-20补上手动连线对象归属到设备 `QETWires_*` 组,连线树结构现在和设备模板一致;已用单元测试验证。 - 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已用单元测试验证端子语义写入和模板校验逻辑。
``` ```

@ -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`.

@ -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. 用电流互感器模型手工验证一次完整流程。

@ -7,6 +7,7 @@ set(FreeCADExchange_Scripts
DevicePreview.py DevicePreview.py
TerminalObjects.py TerminalObjects.py
TemplateSemantics.py TemplateSemantics.py
TemplateAuthoring.py
TerminalImport.py TerminalImport.py
ExchangeWriteBack.py ExchangeWriteBack.py
ManualWiring.py ManualWiring.py

@ -14,6 +14,7 @@ except ImportError:
import ExchangeBootstrap import ExchangeBootstrap
import ExchangeWriteBack import ExchangeWriteBack
import ManualWiring import ManualWiring
import TemplateAuthoring
def _append_init_log(message): def _append_init_log(message):
@ -47,5 +48,10 @@ try:
except Exception: except Exception:
pass pass
try:
TemplateAuthoring.register_commands()
except Exception:
pass
QtCore.QTimer.singleShot(0, ExchangeBootstrap.bootstrap_if_requested) QtCore.QTimer.singleShot(0, ExchangeBootstrap.bootstrap_if_requested)

@ -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()

@ -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()
Loading…
Cancel
Save