zhanghao 6 days ago
commit 6b8ffb4036

1
.gitignore vendored

@ -88,3 +88,4 @@ ipch/
/Run-FreeCAD-E.bat /Run-FreeCAD-E.bat
/path-backup-HKCU-Environment.reg /path-backup-HKCU-Environment.reg
/path-backup-HKLM-Environment.reg /path-backup-HKLM-Environment.reg
/.worktrees/

@ -10,7 +10,7 @@
这些内容统一放在: 这些内容统一放在:
- [数据库设计.md](D:\project\LightWork3D\FreeCAD\docs\数据库设计.md) - [数据库设计.md](D:\LightWork3D\docs\数据库设计.md)
--- ---
@ -382,7 +382,34 @@
让 FreeCAD 只负责消费结果。 让 FreeCAD 只负责消费结果。
### 7.4 为什么这里允许同时带 `parts_3d` ### 8.5 `.FCStd` 设备资产支持
A 方案下,`.FCStd` 是正式可复用设备资产格式。QET 导出时不需要解析 `.FCStd` 内部内容,只需要把设备资产路径解析成 `resolved_model_path` 交给 FreeCAD。
示例:
```json
{
"element_uuid": "elem-1001",
"device_id": 123,
"parts_3d": "models/mccb/MCCB_1P.FCStd",
"resolved_model_path": "C:/Users/Admin/Documents/MingTuProject/models/mccb/MCCB_1P.FCStd"
}
```
FreeCAD 根据 `resolved_model_path` 的扩展名导入 `.FCStd`,并在导入后的设备组内优先扫描 `Role="Terminal"` 的 LCS 作为模板端子槽位。
如果 QET 侧已经在 `device_3d_asset.format` 中保存了资源格式,可以在后续协议中追加可选字段:
```json
{
"format": "fcstd"
}
```
第一版 FreeCAD 不应强依赖该字段,避免旧版本 JSON 缺少 `format` 时无法导入。
### 8.6 为什么这里允许同时带 `parts_3d`
注意: 注意:

@ -243,6 +243,71 @@ 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 绑定到模板槽位上。
### 13.2 A 方案下 zwl 与 FreeCAD 的文件职责
A 方案要求 `D:\code\zwl` 支持 `.FCStd` 作为设备 3D 资产格式,但不要求 zwl 解析 `.FCStd` 文件。
职责划分如下:
- zwl/QET选择 `.FCStd`、复制到工程资产目录、保存到 `device_3d_asset`、导出 `resolved_model_path`
- FreeCADExchange导入 `.FCStd`、扫描模板内 `Role="Terminal"` 的 LCS、创建工程端子对象、保存 3D 装配和手动连线。
也就是说,`.FCStd` 内部的端子、LCS、动态属性和装配结构都是 FreeCAD 侧语义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. 电气柜与设备装配 ## 14. 电气柜与设备装配
除了端子与接线,电气柜场景通常还会遇到另一个基础问题: 除了端子与接线,电气柜场景通常还会遇到另一个基础问题:
@ -630,6 +695,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 +767,12 @@ LCS 不负责:
- 2D 设备能不能在 3D 找到对应设备 - 2D 设备能不能在 3D 找到对应设备
- 2D 端子能不能在 3D 找到对应连接点 - 2D 端子能不能在 3D 找到对应连接点
补充说明:
- 项目端子创建前,优先读取 FCStd 设备模板中的 `Role="Terminal"` LCS。
- 如果模板没有端子语义,才使用 sidecar 或 bbox fallback。
- 长期目标是让常用设备都具备 FCStd 模板端子,而不是长期依赖 fallback。
### 阶段 2让 3D 连线只认端子对象 ### 阶段 2让 3D 连线只认端子对象
下一步完成: 下一步完成:

@ -0,0 +1,601 @@
# FreeCAD 端子显示、手动连线、保存回写开发文档
## 1. 当前任务边界
本文档只面向当前由一个人负责的 FreeCAD 3D 工作台能力:
- 端子显示
- 端子手动连线
- FreeCAD 文档保存
- 最小 3D -> 2D 回写
当前不做:
- 自动布线
- `D:\code\zwl\sources\ThreeD` 旧 3D 引擎扩展
- 复杂 UI 改造
- FreeCAD 核心 C/C++ 源码改造
- 3D 位姿写入数据库
- 新增或依赖旧 3D 场景表
第一版 3D 真相源是 FreeCAD 文档,即 `scene.FCStd`。QET/zwl 只负责 2D 数据、交换 JSON、启动 FreeCAD、后续读取最小回写结果。
## 2. 已有代码与文档现状
### 2.1 LightWork3D / FreeCAD 侧
已有模块:
- `src/Mod/FreeCADExchange/ExchangeBootstrap.py`
- `src/Mod/FreeCADExchange/DeviceImport.py`
- `src/Mod/FreeCADExchange/InitGui.py`
当前已经做到:
- 从环境变量 `QET_2D_TO_3D_JSON` 读取 `2d_to_3d.json`
- 校验 `project_uuid / devices / terminals / device_models`
- 根据 `resolved_model_path` 导入 STEP / IGES / FCStd 等本地模型
- 将导入的设备放入 `QETExchangeDevices` 根组
当前还没有真正完成:
- 根据 `terminals` 创建设备端子对象
- 端子对象的 LCS / 标记属性
- 端子之间的手动连线
- `3d_to_2d.json` 回写
### 2.2 zwl / QET 侧
已有模块:
- `sources/FreeCAD/FreeCADExchangeExportService.cpp`
- `sources/FreeCAD/FreeCADLaunchService.cpp`
当前已经做到:
- 导出 `<ProjectRoot>/.qet_freecad/2d_to_3d.json`
- 返回 `<ProjectRoot>/.qet_freecad/scene.FCStd`
- 启动 FreeCAD并设置
- `QET_2D_TO_3D_JSON`
- `QET_FREECAD_SCENE_FILE`
第一版数据库约束:
- 只允许依赖 `project_2d3d_symbol_binding`
- 只允许依赖 `project_2d3d_terminal_binding`
- 设备绑定只依赖 `project_uuid / element_uuid / instance_id`
- 端子绑定只依赖 `project_uuid / terminal_uuid / instance_id`
- 第一版端子绑定唯一依据是 `terminal_uuid`
### 2.3 代码落地约束
当前第一版实现优先放在:
```text
D:\LightWork3D\src\Mod\FreeCADExchange
```
实现形式优先使用 Python 文件,例如:
```text
src/Mod/FreeCADExchange/DeviceImport.py
src/Mod/FreeCADExchange/TerminalImport.py
src/Mod/FreeCADExchange/ManualWiring.py
src/Mod/FreeCADExchange/ExchangeWriteBack.py
```
第一版尽量只改 FreeCAD 插件层:
- 可以新增 `FreeCADExchange` 下的 `.py` 文件。
- 可以修改 `FreeCADExchange\ExchangeBootstrap.py` 做流程调度。
- 可以修改 `FreeCADExchange\InitGui.py` 注册简单命令。
- 可以修改 `FreeCADExchange\CMakeLists.txt`,把新增 `.py` 加入安装/复制列表。
第一版尽量不改:
- FreeCAD 核心 C/C++ 源码。
- `D:\code\zwl\sources\ThreeD` 旧 3D 模块。
- 数据库旧 3D 场景表。
这样做的好处是:你的端子显示、手动连线、保存回写可以先作为 FreeCAD 工作台插件跑通;后续 csm 改 UI、qdj 做自动布线时,可以直接调用这些 Python 层能力,不需要他们理解或修改 FreeCAD 核心源码。
## 3. 本地设备模板是否可以使用
可以,而且第一版建议优先使用本地模板。
QET 导出的 `device_models[].resolved_model_path` 本来就是给 FreeCAD 使用的本地模型路径。FreeCADExchange 当前支持导入:
- `.step`
- `.stp`
- `.iges`
- `.igs`
- `.brep`
- `.brp`
- `.fcstd`
因此本地 STEP 文件可以直接作为第一批几何资源导入。
但从当前开发方向开始,正式可复用的设备模板建议统一保存为 `.FCStd`。也就是说:
```text
STEP / STP / STE 原始几何
-> 在 FreeCAD 里添加端子 LCS 和电气语义
-> 保存为 FCStd 设备模板
-> 后续不同工程、不同人员统一使用这个 FCStd
```
STEP / STP / STE 适合作为模板制作的输入,不建议作为长期带电气语义的最终交付文件。因为 STEP 可以稳定保存几何,但不能可靠保存 FreeCAD LCS、动态属性、端子角色、接线资格等二次开发语义。
但需要注意:
> 本地 STEP 只提供几何,不天然提供“哪个位置是端子”。
所以要实现端子显示和端子连线,本地模板还必须补一层端子语义。第一版采用下面的优先级:
1. 正式方式:使用 FCStd 模板,在模板里提前放好 LCS 端子对象。
2. 过渡方式STEP + sidecar JSON在同目录下保存端子槽位坐标。
3. 验证方式:没有模板语义时,临时使用 bbox fallback 生成端子位置。
sidecar 只作为 FreeCAD 端模板辅助文件,不进入第一版数据库绑定主键。
sidecar 里除了端子坐标,还可以继续补端子朝向,例如 `rotation`,让模板端子不只是“有位置”,还可以“有方向”。
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` 后,自动识别模板端子并生成工程端子对象。
### 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 只作为模板制作的原始几何输入。
完整流转固定为:
```text
STEP / STP / STE 原始几何
-> FreeCAD 中添加端子 LCS 和模板语义
-> 保存为 FCStd 设备模板
-> zwl/QET 选择该 FCStd 作为设备 3D 资产
-> 3D 视图导出 2d_to_3d.json
-> FreeCAD 导入 FCStd 并绑定工程端子
-> 手动连线并保存 scene.FCStd
-> 生成 3d_to_2d.json
```
zwl/QET 侧只需要支持 `.FCStd` 的选择、复制、保存和导出路径,不解析 `.FCStd` 内部的 LCS、端子属性或装配结构。`.FCStd` 的内部语义由 FreeCADExchange 读取。
详细设计见:
- `docs/superpowers/specs/2026-05-20-freecad-fcstd-asset-flow-design.md`
## 4. 为什么要先落地设备模板
这里的“设备模板”不是要求先把所有设备都建完,而是要先有一个稳定样板,证明端子显示和连线可以依附在真实设备上。
原因:
- 端子必须依附在设备实例上,不能只是空间中的孤立点。
- 端子连线需要稳定的起点、终点和出线方向。
- STEP 普通顶点不稳定,不适合作为端子主对象。
- 如果没有模板层,后续每种设备都要手工猜端子位置。
- 自动布线后续会依赖当前手动布线沉淀出的端子对象和路径对象。
因此第一版只需要选 1 到 2 个设备样板,例如断路器、端子排或继电器,把模板约定跑通。
## 5. 第一版对象模型
### 5.1 设备实例组
每个 2D 设备实例在 FreeCAD 中对应一个设备组。
建议对象:
```text
QETExchangeDevices
QETDevice_<element_uuid>
imported model objects
QETTerminals
QETWires
```
设备组至少保存属性:
- `QetProjectUuid`
- `QetElementUuid`
- `QetInstanceId`
- `QetResolvedModelPath`
`QetInstanceId` 如果输入为空,由 FreeCAD 生成,并在保存回写时写入 `3d_to_2d.json`
### 5.2 端子对象
端子使用 FreeCAD LCS 表示。推荐对象为:
```text
Datum CoordinateSystem + Role="Terminal"
```
端子对象至少保存属性:
- `QetProjectUuid`
- `QetElementUuid`
- `QetTerminalUuid`
- `QetInstanceId`
- `Role = "Terminal"`
- `CanWire = true`
端子判断规则:
1. 对象必须有 `Role`
2. `Role == "Terminal"`
3. 对象必须有 `QetTerminalUuid`
4. `CanWire == true`
第一版禁止把 `terminal_key``connection_point_key` 当作绑定主键。模板内部可以有槽位名,但最终业务识别只认 `terminal_uuid`
### 5.3 手动连线对象
手动连线可以先用 FreeCAD 中的折线对象表示,例如 Draft Wire 或 Part 曲线。
连线对象至少保存属性:
- `QetProjectUuid`
- `QetStartTerminalUuid`
- `QetEndTerminalUuid`
- `QetStartInstanceId`
- `QetEndInstanceId`
- `RouteType = "Manual"`
连线对象建议挂在对应设备实例下的 `QETWires_<element_uuid>` 组里,优先跟随起点端子所属设备。
第一版连线路径保存在 `scene.FCStd`。是否把路径几何回写给 QET后续单独扩协议不在当前最小数据库绑定范围内。
## 6. 推荐文件结构
`src/Mod/FreeCADExchange` 下逐步拆分模块:
```text
FreeCADExchange/
ExchangeBootstrap.py # 启动入口,读取 JSON调度导入流程
DeviceImport.py # 设备导入,已存在
TemplateSemantics.py # 新增:读取 FCStd LCS 或 STEP sidecar 端子槽位
TemplateAuthoring.py # 计划新增:把 STEP/STP/STE 制作为带端子语义的 FCStd 模板
TemplateAuthoringPanel.py # 新增CAD 人员使用的端子制作任务面板
TerminalImport.py # 新增:根据 terminals 创建/更新端子对象
TerminalObjects.py # 新增:端子对象属性、查找、校验工具
ManualWiring.py # 新增:端子选择、折线路径创建、连线对象属性
ExchangeWriteBack.py # 新增:生成 3d_to_2d.json
InitGui.py # 后续由 UI 改造统一接入命令
CMakeLists.txt # 新增 .py 后必须同步加入 FreeCADExchange_Scripts
```
如果暂时不做 UI可以先暴露 Python 函数和简单命令,保证功能链路可测。
推荐第一版调用链:
```text
ExchangeBootstrap.py
-> DeviceImport.import_devices_from_payload(...)
-> TerminalImport.import_terminals_from_payload(...)
-> ExchangeWriteBack.write_back_if_needed(...) 或由保存命令触发
ManualWiring.py
-> 只允许选择 TerminalObjects.is_terminal_object(...) 认可的端子对象
-> 创建 RouteType="Manual" 的连线对象
-> 保存到 scene.FCStd
```
每新增一个 Python 模块,都要加入 `FreeCADExchange\CMakeLists.txt``FreeCADExchange_Scripts`,否则 Visual Studio 构建/INSTALL 后运行目录里不会出现该脚本。
## 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本地模板导入基线
目标:
- 本地 STEP / FCStd 可以稳定导入。
- 正式设备资源优先使用带端子语义的 FCStd 模板。
- 设备组有 `QetElementUuid / QetInstanceId`
- 重新打开同一个项目时,不重复创建同一设备。
改动点:
- 保持 `DeviceImport.py` 现有逻辑。
- 补齐设备组上的 `QetProjectUuid`
- 如果 `instance_id` 为空,生成稳定的 FreeCAD 实例 ID。
验收:
- 使用本地 FCStd 模板导入设备。
- FreeCAD 树中可看到设备组。
- 关闭并重新打开,不产生重复设备组。
### 阶段 B端子显示
目标:
- 根据 `2d_to_3d.json``terminals` 为设备创建端子对象。
- 每个端子都能在 FreeCAD 场景中被看到、被选中。
实现建议:
- 新增 `TerminalImport.py`
- 按 `element_uuid``instance_id` 找到设备组。
- 为每个 `terminal_uuid` 创建 LCS。
- LCS 放入设备组下的 `QETTerminals` 子组。
端子位置策略:
1. 如果 FCStd 模板已有 `Role="Terminal"` 的 LCS则优先复用模板 LCS。
2. 如果有 sidecar JSON则按 sidecar 坐标创建。
3. 如果只有 STEP则先按设备包围盒生成临时端子排列用于打通流程。
临时端子排列只用于第一版验证,不作为长期物理端子定义。
验收:
- 控制台输出端子创建数量。
- 每个端子对象带 `QetTerminalUuid`
- 选择端子时能确认它属于哪个设备实例。
### 阶段 C端子手动连线
目标:
- 只能从端子连到端子。
- 普通几何点、边、面不能直接作为布线端点。
实现建议:
- 新增 `ManualWiring.py`
- 提供函数:
- `is_terminal_object(obj)`
- `create_manual_wire(start_terminal, end_terminal, waypoints)`
- `list_manual_wires(doc)`
- 第一版路径点可以先使用:
- 起点 LCS 原点
- 用户手动点选的中间点
- 终点 LCS 原点
如果暂时没有完整 UI可先做最小命令
1. 用户选择两个端子对象。
2. 执行命令生成一条直线或折线。
3. 连线对象保存起止端子 UUID。
验收:
- 未选择端子时拒绝创建连线。
- 选择同一个端子作为起终点时拒绝创建。
- 创建成功后,连线对象带起点和终点 UUID。
### 阶段 D保存与回写
目标:
- FreeCAD 保存 `scene.FCStd`
- 生成 `.qet_freecad/3d_to_2d.json`
实现建议:
- 新增 `ExchangeWriteBack.py`
- 从文档中扫描:
- 设备组
- 端子对象
- 手动连线对象
- 第一版正式回写结构遵守现有协议最小集:
```json
{
"schema_version": "1.0",
"project_uuid": "string",
"generated_at": "2026-05-20T10:00:00+08:00",
"instances": [
{
"element_uuid": "string",
"instance_id": "string"
}
],
"terminals": [
{
"terminal_uuid": "string",
"instance_id": "string"
}
]
}
```
连线路径第一版保存在 `.FCStd`。如需 QET 读取线对象,建议后续扩展协议字段,不直接写入现有两张绑定表。
验收:
- 保存后存在 `3d_to_2d.json`
- JSON 中每个设备有 `element_uuid / instance_id`
- JSON 中每个端子有 `terminal_uuid / instance_id`
- 不写入 `project_3d_scene_instance`、`project_3d_space_object`、`project_2d3d_link`。
## 8. 是否能达到图二效果
可以达到同方向效果,但需要分层理解。
图二效果包含:
- 本地模型导入
- 柜体和导轨等场景模型
- 设备装配
- FreeCAD 视图展示
- 模型树层级
本文档当前只保证:
- 本地模板能进入 FreeCAD
- 端子能显示
- 端子能手动连线
- FreeCAD 能保存并回写最小绑定
如果本地模板里包含机柜、导轨和设备,并且导入逻辑按组组织,就可以逐步做出图二那类 FreeCAD 场景。复杂 UI、自动排布、自动布线不属于当前个人任务第一阶段。
## 9. 首批模板建议
第一批不要铺太多设备。建议只选:
1. 机柜或安装板:提供场景参考。
2. DIN 导轨:提供设备摆放参考。
3. 一个断路器或继电器:验证设备端子。
4. 一个端子排:验证多端子排列和连线。
每个模板至少准备:
- 原始几何文件:`.STEP` / `.STP` / `.STE`
- 正式模板文件:`.FCStd`
- 模板内 LCS 端子:`Role="Terminal"`,带槽位名和接线资格
- 可选 sidecar只作为过渡或校验不作为正式交付优先方案
- 模板说明:原点、朝向、尺寸单位、端子数量
常用设备建议优先补齐 `FCStd LCS`,把端子位置从临时的 `bbox fallback` 提升为真实可用坐标。
## 10. 单人开发优先级
建议按下面顺序推进:
1. 不改 UI先把 Python 功能函数跑通。
2. 不碰自动布线,先把端子对象和手动线对象持久化。
3. 不扩旧数据库表,只生成 `3d_to_2d.json`
4. 不追求所有设备,先做一个样板设备跑通闭环。
5. 不让 STEP 顶点直接参与布线,只认带属性的端子 LCS。
## 11. 最小验收场景
准备一个测试项目:
- 2 个设备
- 每个设备至少 2 个端子
- 每个设备绑定一个本地 STEP 或 FCStd 模型
验收步骤:
1. 在 QET 点击打开 FreeCAD。
2. QET 生成 `2d_to_3d.json`
3. FreeCAD 导入设备模型。
4. FreeCAD 为端子创建 LCS。
5. 用户选择两个端子创建手动连线。
6. 保存 FreeCAD 文档。
7. 生成 `3d_to_2d.json`
8. 关闭 FreeCAD 再打开,设备、端子、连线仍存在。
达到以上结果,就说明“端子显示 + 端子连线 + 保存回写”第一版闭环成立。
## 12. 开发结果记录要求
每次开发完成后,都要在本文档末尾追加一条开发记录,至少包含:
- 时间
- 本次实现了什么
- 验证结果
- 未完成或待跟进事项
建议格式:
```text
- 2026-05-20完成端子对象创建与显示逻辑支持 FCStd LCS / sidecar JSON / bbox fallback已验证 FreeCAD 可导入并生成端子对象。待补常用设备真实端子坐标。
- 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已用单元测试验证端子语义写入和模板校验逻辑。
- 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并已同步到运行目录验证模块可导入。
```

@ -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,265 @@
# FreeCAD FCStd 设备资产流转 A 方案设计
## 1. 设计目标
A 方案把 `.FCStd` 定义为正式的 3D 电气设备资产格式。
完整链路是:
```text
STEP / STP / STE 原始几何
-> FreeCAD 模板制作
-> 添加端子 LCS 和电气语义
-> 保存为 FCStd 设备模板
-> zwl/QET 选择该 FCStd 作为设备 3D 资产
-> 3D 视图导出 2d_to_3d.json
-> FreeCAD 导入 FCStd 并绑定工程端子
-> 手动连线并保存 scene.FCStd
-> 生成 3d_to_2d.json
```
这个设计解决的问题是:模型文件交给别人、换工程使用、重新打开工程时,端子位置和接线语义仍然跟着模型走。
## 2. 职责边界
### 2.1 LightWork3D / FreeCAD 负责
- 导入 STEP / STP / STE 原始几何。
- 在模型真实接线位置创建模板端子 LCS。
- 给模板端子写入:
- `Role = "Terminal"`
- `CanWire = true`
- `QetTemplateSlotName`
- `QetTerminalLabel`
- `QetTerminalType`
- 保存为 `.FCStd` 设备模板。
- 项目导入时读取 FCStd 模板里的端子 LCS。
- 根据 `2d_to_3d.json` 把工程级 `terminal_uuid` 绑定到 3D 端子对象。
- 在 FreeCAD 文档里保存设备位姿、装配状态、端子位置和手动连线。
### 2.2 D:\code\zwl / QET 负责
- 支持选择 `.FCStd` 作为设备 3D 资产。
- 将 `.FCStd` 保存到 `device_3d_asset`,格式识别为 `fcstd`
- 继续通过 `device_3d_asset.uri` 优先提供 3D 资源路径。
- 导出 `2d_to_3d.json` 时保持 `parts_3d``resolved_model_path`
- 启动 FreeCAD 并传递:
- `QET_2D_TO_3D_JSON`
- `QET_FREECAD_SCENE_FILE`
zwl 不解析 `.FCStd` 内部结构,不读取 LCS不判断端子位置。`.FCStd` 内部端子语义由 FreeCADExchange 读取。
## 3. 模板与工程实例的区别
### 3.1 FCStd 设备模板保存通用槽位
模板中的端子表示“这个设备模型哪里可以接线”,例如:
```text
电流互感器.FCStd
ModelGeometry
Terminal_P1
Terminal_P2
```
模板端子保存:
```text
QetTemplateSlotName = P1
QetTerminalLabel = P1
Role = Terminal
CanWire = true
```
模板端子不保存:
```text
QetProjectUuid
QetElementUuid
QetTerminalUuid
QetInstanceId
```
### 3.2 工程场景保存真实绑定
工程打开 3D 视图后FreeCAD 根据 `2d_to_3d.json` 创建或更新工程端子对象。
工程端子保存:
```text
QetProjectUuid
QetElementUuid
QetTerminalUuid
QetInstanceId
Role = Terminal
CanWire = true
```
这样同一个 FCStd 模板可以被多个工程复用,而不会把某个工程的端子 UUID 写死在模板里。
## 4. zwl 侧需要支持的最小改动
### 4.1 设备 3D 资产选择
`D:\code\zwl\sources\DeviceManager\deviceeditor.cpp` 中的设备 3D 文件选择过滤器需要加入 `.fcstd`
```text
3D 文件 (*.fcstd *.FCStd *.step *.stp *.iges *.igs *.stl *.obj)
```
提示文案从“STEP/IGES 文件”改成“3D 模型文件”,避免用户以为 FCStd 不是合法资产。
### 4.2 格式识别
`detect3dFormat(...)` / `normalize3dFormatFromPathInternal(...)` 需要把:
```text
.fcstd / .FCStd -> fcstd
```
写入 `device_3d_asset.format`
### 4.3 资产复制
当前设备编辑器选择工程外文件时会复制到工程 3D 资产目录。该流程应继续适用于 `.FCStd`
如果源文件旁边存在 sidecar JSON可以继续复制但对正式 A 方案来说FCStd 内 LCS 是优先来源sidecar 只作为过渡兼容。
### 4.4 导出给 FreeCAD
`FreeCADExchangeExportService.cpp` 已经导出:
```json
{
"parts_3d": "...",
"resolved_model_path": "..."
}
```
`.FCStd` 第一版不需要额外字段也能工作,因为 FreeCAD 可从 `resolved_model_path` 的扩展名判断格式。
后续可以把 `device_3d_asset.format` 作为可选调试字段导出:
```json
{
"format": "fcstd"
}
```
但第一版不应让 FreeCAD 依赖这个字段,避免旧 JSON 失效。
### 4.5 旧 3D 空间对象入口
`qetdiagrameditor.cpp` 里旧的空间对象 STEP/IGES 选择入口属于旧 3D 场景功能。A 方案第一版不建议把它作为设备模板主入口。
如果后续仍要用它给机柜、导轨、安装板选择模型,可以单独扩展为:
```text
空间对象模型 (*.fcstd *.FCStd *.step *.stp *.iges *.igs)
```
但它不负责设备端子语义。
## 5. FreeCAD 侧需要保持的读取优先级
项目导入设备后,端子槽位解析顺序固定为:
1. FCStd 模板内已有的 `Role="Terminal"` LCS。
2. STEP 同目录 sidecar JSON。
3. bbox fallback 临时端子。
长期目标是让常用设备都进入第 1 类。第 3 类只用于没有模板时打通流程,不能作为正式端子位置。
## 6. 手动连线与保存回写
手动连线只允许从工程端子对象连到工程端子对象。
连线对象保存在 `scene.FCStd`,第一版不把导线几何路径写入数据库。
保存时 `3d_to_2d.json` 只回写最小绑定:
```json
{
"schema_version": "1.0",
"project_uuid": "string",
"instances": [
{
"element_uuid": "string",
"instance_id": "string"
}
],
"terminals": [
{
"terminal_uuid": "string",
"instance_id": "string"
}
]
}
```
这符合当前数据库约束:第一版只依赖 `project_2d3d_symbol_binding``project_2d3d_terminal_binding`,不依赖旧 3D 场景表。
## 7. 用户流程
### 7.1 制作设备模板
1. 打开 FreeCAD。
2. 导入厂家 STEP / STP / STE 模型。
3. 在真实接线位置添加端子,例如 `P1`、`P2`。
4. 执行模板端子校验。
5. 保存为 `设备名.FCStd`
### 7.2 在 zwl/QET 中使用模板
1. 打开设备管理或设备属性。
2. 选择 3D 资产。
3. 选择刚制作好的 `.FCStd`
4. 保存设备资产绑定。
5. 在工程中点击 `3D视图`
6. zwl 导出 `2d_to_3d.json` 并启动 FreeCAD。
7. FreeCAD 导入 `.FCStd`,读取模板端子,生成工程端子对象。
8. 用户选择端子并创建手动连线。
9. 保存 FreeCAD 工程,生成 `3d_to_2d.json`
## 8. 第一版验收标准
- zwl 设备 3D 资产选择框能选择 `.FCStd`
- `.FCStd` 能写入 `device_3d_asset`,格式为 `fcstd`
- `2d_to_3d.json``resolved_model_path` 指向 `.FCStd`
- FreeCAD 能导入该 `.FCStd`
- 模板中的 LCS 端子优先用于工程端子创建。
- 工程端子对象带真实 `terminal_uuid`
- 用户能选择两个端子生成手动连线。
- 保存 FreeCAD 后生成 `3d_to_2d.json`
- 不依赖 `project_3d_scene_instance`、`project_3d_space_object`、`project_2d3d_link`、`start_end_terminal_matches`。
## 9. 实施顺序
1. 在 zwl 侧补 `.FCStd` 文件过滤、格式识别和提示文案。
2. 确认 `.FCStd` 资产复制、哈希、保存流程不被阻断。
3. 确认 `2d_to_3d.json` 能输出 `.FCStd``resolved_model_path`
4. 在 FreeCAD 中用现有 `TemplateAuthoring.py` 制作一个样板设备。
5. 用该 FCStd 样板设备跑通导入、端子显示、手动连线、保存回写。
6. 再补更友好的模板端子编辑 UI。
## 10. 风险和处理
`.FCStd` 无法被 zwl 预览尺寸:
- 不阻断资产绑定。
- 尺寸字段可以为空或由后续 FreeCAD 侧读取后回填。
模板端子槽位与 2D 端子数量不一致:
- 第一版按数量顺序匹配可用槽位。
- 后续再引入显式槽位映射 UI。
用户误选普通 STEP
- 仍允许导入,但只能走 sidecar 或 bbox fallback。
- 文档和提示中明确“正式可复用资产推荐 FCStd”。
旧空间对象入口和新设备模板入口混淆:
- 第一版设备端子语义只走设备 3D 资产入口。
- 旧空间对象入口只作为柜体、导轨、安装板模型入口。

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

@ -0,0 +1,190 @@
# FreeCAD 端子模板语义设计
> 目标:把端子位置、端子命名、端子朝向和接线资格从几何模型里分离出来,放进可维护的模板语义层。
**Goal:** 让 FreeCAD 的端子显示、手动连线和最小保存回写依赖真实模板语义,而不是长期依赖包围盒猜位。
**Architecture:** 以 `FreeCADExchange` 的 Python 层为主体。`DeviceImport.py` 负责把设备模型导入到设备组,`TemplateSemantics.py` 负责把模板输入统一成端子槽位,`TerminalImport.py` 负责创建或更新端子 LCS`ManualWiring.py` 负责端子到端子的人工连线,`ExchangeWriteBack.py` 负责生成 `3d_to_2d.json`。FreeCAD 文档 `scene.FCStd` 是 3D 状态真相源,数据库只保留第一版最小绑定表。
**Tech Stack:** FreeCAD Python API, `App::DocumentObjectGroup`, `Part::LocalCoordinateSystem` / `PartDesign::CoordinateSystem`, JSON sidecar files, FreeCAD document observer.
---
## 1. 设计目标
1. 端子必须有稳定的语义来源。
2. 端子必须能被看见、选中、更新。
3. 手动连线只能从端子连到端子。
4. 保存时只回写第一版最小结果,不把 3D 位姿塞回数据库。
5. 常用设备最终要补成真实模板,不能长期靠 `bbox fallback`
## 2. 设计边界
本设计只覆盖以下内容:
- 端子模板语义解析
- 端子对象创建 / 更新
- 手动连线对象创建
- 保存时生成 `3d_to_2d.json`
本设计不覆盖:
- 自动布线
- FreeCAD C/C++ 核心修改
- `D:\code\zwl\sources\ThreeD` 旧 3D 引擎
- 新增 3D 场景数据库表
- 将完整线几何回写到 QET
## 3. 模板语义优先级
端子位置来源按下面优先级解析:
1. `FCStd` 模板里已有的 `LCS`
2. 同目录 sidecar JSON
3. `bbox fallback`
解释:
- `FCStd` 模板是正式答案,适合已经整理过的常用设备。
- sidecar JSON 是过渡方案,适合 STEP 几何已经有了,但还没整理成 FCStd 模板的设备。
- `bbox fallback` 只用于打通流程,不代表真实端子位置。
## 4. 模板资产约定
### 4.1 FCStd 模板
FCStd 模板里,端子用 LCS 表达,建议对象满足:
- 类型是 `Part::LocalCoordinateSystem``PartDesign::CoordinateSystem`
- `Role = "Terminal"`
- `CanWire = true`
这些属性的作用是:
- `Role` 让 FreeCADExchange 识别它是端子
- `CanWire` 让连线命令只接受有效端子
- 端子对象的 `QetTerminalUuid` 由导入流程补齐,不依赖模板文件本身已有的 2D 绑定字段
### 4.2 sidecar JSON
sidecar JSON 与模型同目录,当前支持的文件名后缀约定:
- `.terminals.json`
- `.qet_terminals.json`
- `.terminal_slots.json`
- `.qet_template.json`
sidecar 中可接受的槽位字段:
- `name`
- `label`
- `position`
- `base`
- `origin`
- `point`
- `x / y / z`
如果某个槽位已经有明确位置,就直接作为端子坐标。名称和标签只用于显示和调试,不作为业务主键。
### 4.3 bbox fallback
当模板里没有 LCS也没有 sidecar JSON 时,才用包围盒生成临时端子位置。
这个 fallback 只做三件事:
- 让导入链路跑通
- 让用户能先看到端子对象
- 给后续补模板留出验证空间
它不能作为最终模板方案。
## 5. 端子对象模型
每个端子对象都必须挂在对应设备实例下的 `QETTerminals_<element_uuid>` 组内。
端子对象必须保存这些语义属性:
- `QetProjectUuid`
- `QetElementUuid`
- `QetTerminalUuid`
- `QetInstanceId`
- `Role = "Terminal"`
- `CanWire = true`
补充属性:
- `QetTemplateSlotName`
识别规则只认 `terminal_uuid`,不依赖 `terminal_key`、`connection_point_key` 这类旧字段。
## 6. 连线对象模型
手动连线只允许连接两个端子。
连线对象保存:
- `QetProjectUuid`
- `QetStartTerminalUuid`
- `QetEndTerminalUuid`
- `QetStartInstanceId`
- `QetEndInstanceId`
- `RouteType = "Manual"`
连线几何保存在 `scene.FCStd`,第一版不把完整路径几何写回数据库。
## 7. 数据流
1. QET 导出 `2d_to_3d.json`
2. FreeCAD 读取 `devices / terminals / device_models`
3. `DeviceImport.py` 导入设备模型并形成 `QETExchangeDevices` 树。
4. `TemplateSemantics.py` 为每个设备解析端子槽位。
5. `TerminalImport.py` 创建或更新端子对象。
6. 用户通过命令手动连线。
7. 保存文档时 `ExchangeWriteBack.py` 输出 `3d_to_2d.json`
## 8. 实现约束
1. 第一版只改 `FreeCADExchange` Python 层。
2. 第一版不改 FreeCAD C/C++。
3. 第一版不依赖旧 3D 场景表。
4. 第一版 3D 状态以 FreeCAD 文档为准。
5. 第一版绑定只认:
- `project_uuid`
- `element_uuid`
- `terminal_uuid`
- `instance_id`
## 9. 模板建设顺序
建议先补这几类常用设备:
1. 机柜 / 安装板
2. DIN 导轨
3. 断路器或继电器
4. 端子排
先把这些设备补成 FCStd LCS 或 sidecar JSON可以最快验证“端子显示 + 手动连线 + 保存回写”这条链路。
## 10. 失败处理
1. 模型文件不存在,跳过设备并记录警告。
2. 没有端子模板,退回 sidecar。
3. 没有 sidecar退回 bbox fallback。
4. 端子 `terminal_uuid` 缺失,直接跳过该条。
5. 同一个 `terminal_uuid` 已存在时,更新旧端子而不是重复创建。
6. 保存回写失败时,保留 FreeCAD 文档状态,错误写入调试日志。
## 11. 验收标准
满足下面结果,就说明这套设计可用:
- 设备模型导入后,树里能看到 `QETDevice_<element_uuid>`
- 每个设备下有对应的 `QETTerminals_<element_uuid>`
- 端子对象带 `QetTerminalUuid``Role="Terminal"`
- 用户只能从端子连到端子
- 保存后生成 `3d_to_2d.json`
- `3d_to_2d.json` 里只保留设备实例和端子实例的最小回写结果
## 12. 结论
这条路线的核心不是“先把所有模型做完”,而是先把模板语义建立起来。只要常用设备的端子位置从 fallback 升级到 FCStd LCS 或 sidecar JSONFreeCAD 这条 3D 线就能从临时验证变成可持续开发。

@ -351,7 +351,7 @@ void StdCmdRestartInSafeMode::activated(int iMsg)
QMessageBox restartBox(Gui::getMainWindow()); QMessageBox restartBox(Gui::getMainWindow());
restartBox.setIcon(QMessageBox::Warning); restartBox.setIcon(QMessageBox::Warning);
restartBox.setWindowTitle(QObject::tr("Restart in Safe Mode")); restartBox.setWindowTitle(QObject::tr("Restart in Safe Mode"));
restartBox.setText(QObject::tr("Restart FreeCAD and enter safe mode?")); restartBox.setText(QObject::tr("Restart Light works 3D and enter safe mode?"));
restartBox.setInformativeText( restartBox.setInformativeText(
QObject::tr("Safe mode temporarily disables the configuration and addons.") QObject::tr("Safe mode temporarily disables the configuration and addons.")
); );

@ -268,12 +268,12 @@
&lt;html&gt;&lt;head&gt;&lt;meta name="qrichtext" content="1" /&gt;&lt;style type="text/css"&gt; &lt;html&gt;&lt;head&gt;&lt;meta name="qrichtext" content="1" /&gt;&lt;style type="text/css"&gt;
p, li { white-space: pre-wrap; } p, li { white-space: pre-wrap; }
&lt;/style&gt;&lt;/head&gt;&lt;body style=" font-family:'Fira Sans'; font-size:9pt; font-weight:400; font-style:normal;"&gt; &lt;/style&gt;&lt;/head&gt;&lt;body style=" font-family:'Fira Sans'; font-size:9pt; font-weight:400; font-style:normal;"&gt;
&lt;p style=" margin-top:16px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"&gt;&lt;span style=" font-size:x-large; font-weight:600;"&gt;FreeCAD license &lt;/span&gt;&lt;/p&gt; &lt;p style=" margin-top:16px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"&gt;&lt;span style=" font-size:x-large; font-weight:600;"&gt;Light works 3D license &lt;/span&gt;&lt;/p&gt;
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"&gt;The FreeCAD application is licensed under the terms of the LGPL2+ license, as stated below.&lt;/p&gt; &lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"&gt;The Light works 3D application is licensed under the terms of the LGPL2+ license, as stated below.&lt;/p&gt;
&lt;p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"&gt;&lt;br /&gt;&lt;/p&gt; &lt;p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"&gt;&lt;span style=" font-size:14pt; font-weight:600;"&gt;Third-party libraries licenses&lt;/span&gt;&lt;/p&gt; &lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"&gt;&lt;span style=" font-size:14pt; font-weight:600;"&gt;Third-party libraries licenses&lt;/span&gt;&lt;/p&gt;
&lt;p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-size:14pt; font-weight:600;"&gt;&lt;br /&gt;&lt;/p&gt; &lt;p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-size:14pt; font-weight:600;"&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"&gt;The different libraries used in FreeCAD and their respective licenses are described on the&lt;a href="https://www.freecad.org/wiki/Third_Party_Libraries"&gt;&lt;span style=" text-decoration: underline; color:#0000ff;"&gt;Third Party Libraries wiki page&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt; &lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"&gt;The different libraries used in Light works 3D and their respective licenses are described in the documentation.&lt;/p&gt;
&lt;hr /&gt; &lt;hr /&gt;
&lt;hr /&gt; &lt;hr /&gt;
&lt;hr /&gt; &lt;hr /&gt;

@ -289,7 +289,7 @@ void AboutDialog::setupLabels()
} }
if (url == QStringLiteral("Unknown")) { if (url == QStringLiteral("Unknown")) {
url = QStringLiteral("https://github.com/FreeCAD/FreeCAD"); // Just take a guess url = QStringLiteral("https://github.com/LightWorks/LightWorks3D"); // Just take a guess
} }
// This may only create valid URLs for Github, but some other hosts use the same format // This may only create valid URLs for Github, but some other hosts use the same format
@ -328,7 +328,7 @@ void AboutDialog::showCredits()
QString creditsHTML QString creditsHTML
= QStringLiteral("<html><body><h1>%1</h1><p>%2</p><h2>%3</h2><ul>") = QStringLiteral("<html><body><h1>%1</h1><p>%2</p><h2>%3</h2><ul>")
.arg(tr("Credits", "Header for the Credits tab of the About screen")) .arg(tr("Credits", "Header for the Credits tab of the About screen"))
.arg(tr("FreeCAD would not be possible without the contributions of:")) .arg(tr("Light works 3D would not be possible without the contributions of:"))
.arg(tr("Individuals", "Header for the list of individual people in the Credits list.")); .arg(tr("Individuals", "Header for the list of individual people in the Credits list."));
QTextStream stream(&creditsFile); QTextStream stream(&creditsFile);

@ -176,7 +176,7 @@ QByteArray PythonOnlineHelp::fileNotFound() const
"<table width=\"100%\" cellspacing=0 cellpadding=2 border=0 summary=\"heading\">" "<table width=\"100%\" cellspacing=0 cellpadding=2 border=0 summary=\"heading\">"
"<tr bgcolor=\"#7799ee\">" "<tr bgcolor=\"#7799ee\">"
"<td valign=bottom>&nbsp;<br>" "<td valign=bottom>&nbsp;<br>"
"<font color=\"#ffffff\" face=\"helvetica, arial\">&nbsp;<br><big><big><strong>FreeCAD " "<font color=\"#ffffff\" face=\"helvetica, arial\">&nbsp;<br><big><big><strong>Light works 3D "
"Documentation</strong></big></big></font></td>" "Documentation</strong></big></big></font></td>"
"<td align=right valign=bottom>" "<td align=right valign=bottom>"
"<font color=\"#ffffff\" face=\"helvetica, arial\">&nbsp;</font></td></tr></table>" "<font color=\"#ffffff\" face=\"helvetica, arial\">&nbsp;</font></td></tr></table>"
@ -211,7 +211,7 @@ QByteArray PythonOnlineHelp::loadFailed(const QString& error) const
"<tr bgcolor=\"#7799ee\">" "<tr bgcolor=\"#7799ee\">"
"<td valign=bottom>&nbsp;<br>" "<td valign=bottom>&nbsp;<br>"
"<font color=\"#ffffff\" face=\"helvetica, " "<font color=\"#ffffff\" face=\"helvetica, "
"arial\">&nbsp;<br><big><big><strong>FreeCAD " "arial\">&nbsp;<br><big><big><strong>Light works 3D "
"Documentation</strong></big></big></font></td>" "Documentation</strong></big></big></font></td>"
"<td align=right valign=bottom>" "<td align=right valign=bottom>"
"<font color=\"#ffffff\" face=\"helvetica, arial\">&nbsp;</font></td></tr></table>" "<font color=\"#ffffff\" face=\"helvetica, arial\">&nbsp;</font></td></tr></table>"

@ -815,13 +815,6 @@ MenuItem* StdWorkbench::setupMenuBar() const
// Help // Help
auto help = new MenuItem(menuBar); auto help = new MenuItem(menuBar);
help->setCommand("&Help"); help->setCommand("&Help");
*help << "Std_WhatsThis"
<< "Separator"
// Start page and additional separator are dynamically inserted here
<< "Std_FreeCADUserHub" << "Std_FreeCADForum" << "Std_ReportBug" << "Separator"
<< "Std_RestartInSafeMode" << "Separator"
<< "Std_DevHandbook" << "Std_PythonHelp" << "Separator"
<< "Std_FreeCADWebsite" << "Std_FreeCADDonation" << "Std_About";
return menuBar; return menuBar;
} }
@ -879,11 +872,6 @@ ToolBarItem* StdWorkbench::setupToolBars() const
structure->setCommand("Structure"); structure->setCommand("Structure");
*structure << "Std_Part" << "Std_Group" << "Std_LinkActions" << "Std_VarSet"; *structure << "Std_Part" << "Std_Group" << "Std_LinkActions" << "Std_VarSet";
// Help
auto help = new ToolBarItem(root);
help->setCommand("Help");
*help << "Std_WhatsThis";
return root; return root;
} }
@ -1027,7 +1015,6 @@ MenuItem* NoneWorkbench::setupMenuBar() const
// Help // Help
auto help = new MenuItem(menuBar); auto help = new MenuItem(menuBar);
help->setCommand("&Help"); help->setCommand("&Help");
*help << "Std_OnlineHelp" << "Std_About";
return menuBar; return menuBar;
} }
@ -1067,7 +1054,9 @@ MenuItem* TestWorkbench::setupMenuBar() const
MenuItem* menuBar = StdWorkbench::setupMenuBar(); MenuItem* menuBar = StdWorkbench::setupMenuBar();
MenuItem* item = menuBar->findItem("&Help"); MenuItem* item = menuBar->findItem("&Help");
item->removeItem(item->findItem("Std_WhatsThis")); if (auto whatsThis = item->findItem("Std_WhatsThis")) {
item->removeItem(whatsThis);
}
// Test commands // Test commands
auto test = new MenuItem; auto test = new MenuItem;

@ -55,8 +55,8 @@
void PrintInitHelp(); void PrintInitHelp();
const auto sBanner = fmt::format( const auto sBanner = fmt::format(
"(C) 2001-{} FreeCAD contributors\n" "(C) 2001-{} LightWorks contributors\n"
"FreeCAD is free and open-source software licensed under the terms of LGPL2+ license.\n\n", "Light works 3D is free and open-source software licensed under the terms of LGPL2+ license.\n\n",
FCCopyrightYear FCCopyrightYear
); );
@ -188,10 +188,10 @@ int main(int argc, char** argv)
#endif #endif
// Name and Version of the Application // Name and Version of the Application
App::Application::Config()["ExeName"] = "FreeCAD"; App::Application::Config()["ExeName"] = "Light works 3D";
App::Application::Config()["ExeVendor"] = "FreeCAD"; App::Application::Config()["ExeVendor"] = "LightWorks";
App::Application::Config()["AppDataSkipVendor"] = "true"; App::Application::Config()["AppDataSkipVendor"] = "true";
App::Application::Config()["MaintainerUrl"] = "https://freecad.org"; App::Application::Config()["MaintainerUrl"] = "";
// set the banner (for logging and console) // set the banner (for logging and console)
App::Application::Config()["CopyrightInfo"] = sBanner; App::Application::Config()["CopyrightInfo"] = sBanner;
@ -207,7 +207,7 @@ int main(int argc, char** argv)
App::Application::Config()["SplashWarningColor"] = "#CA333B"; App::Application::Config()["SplashWarningColor"] = "#CA333B";
App::Application::Config()["SplashInfoColor"] = "#000000"; App::Application::Config()["SplashInfoColor"] = "#000000";
App::Application::Config()["SplashInfoPosition"] = "6,75"; App::Application::Config()["SplashInfoPosition"] = "6,75";
App::Application::Config()["DesktopFileName"] = "org.freecad.FreeCAD"; App::Application::Config()["DesktopFileName"] = "com.lightworks.LightWorks3D";
try { try {
// Init phase =========================================================== // Init phase ===========================================================

@ -5,6 +5,13 @@ set(FreeCADExchange_Scripts
ExchangeBootstrap.py ExchangeBootstrap.py
DeviceImport.py DeviceImport.py
DevicePreview.py DevicePreview.py
TerminalObjects.py
TemplateSemantics.py
TemplateAuthoring.py
TemplateAuthoringPanel.py
TerminalImport.py
ExchangeWriteBack.py
ManualWiring.py
) )
add_custom_target(FreeCADExchangeScripts ALL add_custom_target(FreeCADExchangeScripts ALL

@ -1,5 +1,6 @@
import os import os
from pathlib import Path from pathlib import Path
import uuid
import FreeCAD as App import FreeCAD as App
import FreeCADGui as Gui import FreeCADGui as Gui
@ -11,6 +12,10 @@ ROOT_GROUP_NAME = "QETExchangeDevices"
ROOT_GROUP_LABEL = "QET Exchange Devices" ROOT_GROUP_LABEL = "QET Exchange Devices"
CABINET_MODEL_GROUP_NAME = "QETCabinetModel" CABINET_MODEL_GROUP_NAME = "QETCabinetModel"
DEVICE_GROUP_PREFIX = "QETDevice_" DEVICE_GROUP_PREFIX = "QETDevice_"
TERMINAL_GROUP_PREFIX = "QETTerminals_"
WIRE_GROUP_PREFIX = "QETWires_"
GROUP_KIND_TERMINALS = "Terminals"
GROUP_KIND_WIRES = "Wires"
class DeviceImportError(RuntimeError): class DeviceImportError(RuntimeError):
@ -88,6 +93,60 @@ def _ensure_bool_property(obj, prop_name, group_name, description, value):
setattr(obj, prop_name, bool(value)) setattr(obj, prop_name, bool(value))
def _ensure_child_group(doc, parent_group, element_uuid, instance_id, name_prefix, label, group_kind, project_uuid=""):
target_uuid = (element_uuid or "").strip()
preferred_name = name_prefix + _safe_token(target_uuid)
group = doc.getObject(preferred_name)
if group is None:
for candidate in getattr(parent_group, "Group", []) or []:
if getattr(candidate, "QetGroupKind", "").strip() != group_kind:
continue
if target_uuid and getattr(candidate, "QetElementUuid", "").strip() != target_uuid:
continue
group = candidate
break
if group is None:
group = doc.addObject("App::DocumentObjectGroup", preferred_name)
if group not in getattr(parent_group, "Group", []):
parent_group.addObject(group)
group.Label = label
project_uuid = (project_uuid or "").strip() or getattr(group, "QetProjectUuid", "").strip()
element_uuid = (element_uuid or "").strip() or getattr(group, "QetElementUuid", "").strip()
instance_id = (instance_id or "").strip() or getattr(group, "QetInstanceId", "").strip()
_ensure_string_property(
group,
"QetGroupKind",
"QET Exchange",
"FreeCADExchange group kind",
group_kind,
)
_ensure_string_property(
group,
"QetProjectUuid",
"QET Exchange",
"Project UUID from QET exchange",
project_uuid,
)
_ensure_string_property(
group,
"QetElementUuid",
"QET Exchange",
"Parent element UUID from QET exchange",
element_uuid,
)
_ensure_string_property(
group,
"QetInstanceId",
"QET Exchange",
"Parent instance id from QET exchange",
instance_id,
)
return group
def _ensure_document(scene_path): def _ensure_document(scene_path):
preferred_name = _safe_token(Path(scene_path).stem if scene_path else "QETScene")[:48] or "QETScene" preferred_name = _safe_token(Path(scene_path).stem if scene_path else "QETScene")[:48] or "QETScene"
existing_doc = DevicePreview.find_main_exchange_document(preferred_name) existing_doc = DevicePreview.find_main_exchange_document(preferred_name)
@ -116,7 +175,7 @@ def _cabinet_label_text(cabinet):
return "QET Cabinet" return "QET Cabinet"
def _ensure_root_group(doc, cabinet=None): def _ensure_root_group(doc, cabinet=None, project_uuid=""):
root = doc.getObject(ROOT_GROUP_NAME) root = doc.getObject(ROOT_GROUP_NAME)
if root is None: if root is None:
root = doc.addObject("App::DocumentObjectGroup", ROOT_GROUP_NAME) root = doc.addObject("App::DocumentObjectGroup", ROOT_GROUP_NAME)
@ -174,6 +233,14 @@ def _ensure_root_group(doc, cabinet=None):
"Cabinet location id from QET exchange", "Cabinet location id from QET exchange",
str(cabinet.get("location_id") or "") if isinstance(cabinet, dict) else "", str(cabinet.get("location_id") or "") if isinstance(cabinet, dict) else "",
) )
project_uuid = (project_uuid or "").strip() or getattr(root, "QetProjectUuid", "").strip()
_ensure_string_property(
root,
"QetProjectUuid",
"QET Exchange",
"Project UUID from QET exchange",
project_uuid,
)
return root return root
@ -281,6 +348,33 @@ def _ensure_device_group(doc, root_group, element_uuid, instance_id, model_path,
) )
if created_now: if created_now:
device_group.Placement = App.Placement() device_group.Placement = App.Placement()
_ensure_string_property(
device_group,
"QetProjectUuid",
"QET Exchange",
"Project UUID from QET exchange",
getattr(root_group, "QetProjectUuid", "").strip(),
)
_ensure_child_group(
doc,
device_group,
element_uuid,
instance_id,
TERMINAL_GROUP_PREFIX,
"QET Terminals",
GROUP_KIND_TERMINALS,
project_uuid=getattr(root_group, "QetProjectUuid", "").strip(),
)
_ensure_child_group(
doc,
device_group,
element_uuid,
instance_id,
WIRE_GROUP_PREFIX,
"QET Wires",
GROUP_KIND_WIRES,
project_uuid=getattr(root_group, "QetProjectUuid", "").strip(),
)
return device_group, created_now return device_group, created_now
@ -298,9 +392,19 @@ def _remove_object_tree(doc, obj):
def _clear_group_contents(doc, group): def _clear_group_contents(doc, group):
for child in list(getattr(group, "Group", []) or []): for child in list(getattr(group, "Group", []) or []):
child_name = getattr(child, "Name", "")
if child_name.startswith(TERMINAL_GROUP_PREFIX) or child_name.startswith(WIRE_GROUP_PREFIX):
continue
if getattr(child, "QetGroupKind", "").strip() in {GROUP_KIND_TERMINALS, GROUP_KIND_WIRES}:
continue
_remove_object_tree(doc, child) _remove_object_tree(doc, child)
def _generate_instance_id(project_uuid, element_uuid):
seed = "QET:{0}:{1}".format((project_uuid or "").strip(), (element_uuid or "").strip())
return str(uuid.uuid5(uuid.NAMESPACE_URL, seed))
def _supported_for_import(model_path): def _supported_for_import(model_path):
suffix = Path(model_path).suffix.lower() suffix = Path(model_path).suffix.lower()
return suffix in { return suffix in {
@ -409,7 +513,8 @@ def import_devices_from_payload(payload, scene_path=""):
_append_debug_log("DeviceImport.import_devices_from_payload entered") _append_debug_log("DeviceImport.import_devices_from_payload entered")
doc = _ensure_document(scene_path) doc = _ensure_document(scene_path)
cabinet = payload.get("cabinet") cabinet = payload.get("cabinet")
root_group = _ensure_root_group(doc, cabinet) project_uuid = (payload.get("project_uuid") or "").strip()
root_group = _ensure_root_group(doc, cabinet, project_uuid)
models_by_element = _model_index(payload) models_by_element = _model_index(payload)
report = { report = {
@ -477,6 +582,13 @@ def import_devices_from_payload(payload, scene_path=""):
continue continue
existing_group = _find_device_group(doc, element_uuid) existing_group = _find_device_group(doc, element_uuid)
if not instance_id:
existing_instance_id = ""
if existing_group is not None:
existing_instance_id = getattr(existing_group, "QetInstanceId", "").strip()
instance_id = existing_instance_id or _generate_instance_id(project_uuid, element_uuid)
report.setdefault("generated_instance_ids", 0)
report["generated_instance_ids"] += 1
device_group, created_now = _ensure_device_group( device_group, created_now = _ensure_device_group(
doc, doc,
root_group, root_group,

@ -7,6 +7,8 @@ import FreeCAD as App
import FreeCADGui as Gui import FreeCADGui as Gui
import DeviceImport import DeviceImport
import DevicePreview import DevicePreview
import ExchangeWriteBack
import TerminalImport
try: try:
from PySide6 import QtCore, QtWidgets from PySide6 import QtCore, QtWidgets
@ -24,6 +26,8 @@ STATE_FLAG = "_qet_exchange_bootstrapped"
STATE_PAYLOAD = "_qet_exchange_payload" STATE_PAYLOAD = "_qet_exchange_payload"
STATE_SUMMARY = "_qet_exchange_summary" STATE_SUMMARY = "_qet_exchange_summary"
STATE_IMPORT_REPORT = "_qet_exchange_import_report" STATE_IMPORT_REPORT = "_qet_exchange_import_report"
STATE_TERMINAL_IMPORT_REPORT = "_qet_exchange_terminal_import_report"
STATE_WRITEBACK_REPORT = "_qet_exchange_writeback_report"
STATE_IMPORT_SCHEDULED = "_qet_exchange_import_scheduled" STATE_IMPORT_SCHEDULED = "_qet_exchange_import_scheduled"
STATE_TREE_FILTER = "_qet_exchange_tree_filter" STATE_TREE_FILTER = "_qet_exchange_tree_filter"
STATE_TREE_SIGNAL_CONNECTIONS = "_qet_exchange_tree_signal_connections" STATE_TREE_SIGNAL_CONNECTIONS = "_qet_exchange_tree_signal_connections"
@ -497,7 +501,7 @@ def _build_summary(payload, json_path):
} }
def _summary_message(summary, import_report=None): def _summary_message(summary, import_report=None, terminal_report=None, writeback_report=None):
lines = [ lines = [
"QET exchange file loaded successfully.", "QET exchange file loaded successfully.",
"", "",
@ -593,9 +597,38 @@ def _summary_message(summary, import_report=None):
if len(warnings) > 10: if len(warnings) > 10:
lines.append("- ... ({0} more)".format(len(warnings) - 10)) lines.append("- ... ({0} more)".format(len(warnings) - 10))
if terminal_report:
lines.extend(
[
"",
"3D terminal import summary:",
"Document: {0}".format(terminal_report["document_name"]),
"Imported terminals: {0}".format(terminal_report["imported_terminals"]),
"Updated terminals: {0}".format(terminal_report["updated_terminals"]),
"Removed stale terminals: {0}".format(terminal_report["removed_terminals"]),
]
)
warnings = terminal_report.get("warnings", [])
if warnings:
lines.append("Warnings:")
lines.extend("- {0}".format(item) for item in warnings[:10])
if len(warnings) > 10:
lines.append("- ... ({0} more)".format(len(warnings) - 10))
if writeback_report:
lines.extend(
[
"",
"3D write-back:",
"Output file: {0}".format(writeback_report["output_path"]),
"Instances written: {0}".format(len(writeback_report["instances"])),
"Terminals written: {0}".format(len(writeback_report["terminals"])),
]
)
lines.append("") lines.append("")
lines.append("This step validates the exchange payload and imports devices with valid resolved model paths.") lines.append("This step validates the exchange payload and imports devices with valid resolved model paths.")
lines.append("3D terminal creation is not running yet.") lines.append("3D terminal import and write-back are enabled.")
return "\n".join(lines) return "\n".join(lines)
@ -641,6 +674,7 @@ def _run_scheduled_device_import(attempt=0):
_show_error("QET Exchange", str(exc)) _show_error("QET Exchange", str(exc))
App.Console.PrintError("[FreeCADExchange] {0}\n".format(exc)) App.Console.PrintError("[FreeCADExchange] {0}\n".format(exc))
return return
except Exception as exc: except Exception as exc:
_append_debug_log("unexpected device import exception: {0}".format(exc)) _append_debug_log("unexpected device import exception: {0}".format(exc))
_append_debug_log(traceback.format_exc()) _append_debug_log(traceback.format_exc())
@ -680,8 +714,54 @@ def _run_scheduled_device_import(attempt=0):
import_report["skipped_missing_model"], import_report["skipped_missing_model"],
) )
) )
if import_report.get("generated_instance_ids"):
App.Console.PrintMessage(
"[FreeCADExchange] Generated device instance IDs: {0}\n".format(
import_report["generated_instance_ids"]
)
)
try:
terminal_report = TerminalImport.import_terminals_from_payload(payload, scene_path)
except TerminalImport.TerminalImportError as exc:
_append_debug_log("terminal import failed: {0}".format(exc))
_show_error("QET Exchange", str(exc))
App.Console.PrintError("[FreeCADExchange] {0}\n".format(exc))
return
except Exception as exc:
_append_debug_log("unexpected terminal import exception: {0}".format(exc))
_append_debug_log(traceback.format_exc())
_show_error("QET Exchange", "Failed to import 3D terminals:\n{0}".format(exc))
App.Console.PrintError(
"[FreeCADExchange] Failed to import terminals: {0}\n".format(exc)
)
return
setattr(App, STATE_TERMINAL_IMPORT_REPORT, terminal_report)
try:
writeback_report = ExchangeWriteBack.write_back_document(
App.ActiveDocument, scene_path=scene_path, payload=payload
)
except Exception as exc:
_append_debug_log("write-back failed after import: {0}".format(exc))
_append_debug_log(traceback.format_exc())
writeback_report = None
else:
setattr(App, STATE_WRITEBACK_REPORT, writeback_report)
App.Console.PrintMessage(
"[FreeCADExchange] Imported terminals: {0}, updated: {1}, removed: {2}\n".format(
terminal_report["imported_terminals"],
terminal_report["updated_terminals"],
terminal_report["removed_terminals"],
)
)
_show_info("QET Exchange", _summary_message(summary, import_report)) _show_info(
"QET Exchange",
_summary_message(summary, import_report, terminal_report, writeback_report),
)
_append_debug_log("summary dialog shown") _append_debug_log("summary dialog shown")

@ -0,0 +1,323 @@
# FreeCADExchange write-back helpers.
import json
import os
from datetime import datetime
from pathlib import Path
import FreeCAD as App
import DeviceImport
import TerminalObjects as TerminalObjects
try:
import FreeCADGui as Gui
except ImportError:
Gui = None
STATE_WRITEBACK_OBSERVER = "_qet_exchange_writeback_observer"
class ExchangeWriteBackError(RuntimeError):
pass
def _append_debug_log(message):
try:
DeviceImport._append_debug_log(message)
except Exception:
pass
def _project_uuid_from_payload(payload):
if isinstance(payload, dict):
value = (payload.get("project_uuid") or "").strip()
if value:
return value
return ""
def _root_group(doc):
try:
return doc.getObject(TerminalObjects.ROOT_GROUP_NAME)
except Exception:
return None
def _is_device_group(obj):
if obj is None:
return False
try:
if not obj.Name.startswith(DeviceImport.DEVICE_GROUP_PREFIX):
return False
return "QetElementUuid" in getattr(obj, "PropertiesList", [])
except Exception:
return False
def _iter_device_groups(doc):
root = _root_group(doc)
if root is not None:
for child in list(getattr(root, "Group", []) or []):
if _is_device_group(child):
yield child
return
for obj in doc.Objects:
if _is_device_group(obj):
yield obj
def _iter_terminal_objects(device_group):
terminal_container = TerminalObjects.find_child_group_by_kind(
device_group,
TerminalObjects.TERMINAL_GROUP_KIND,
)
if terminal_container is None:
return []
return TerminalObjects.collect_terminal_objects(terminal_container)
def _scene_path_from_doc(doc, scene_path=""):
candidate = (scene_path or "").strip()
if candidate:
return candidate
env_scene = os.environ.get("QET_FREECAD_SCENE_FILE", "").strip()
if env_scene:
return env_scene
file_name = getattr(doc, "FileName", "").strip()
if file_name:
return file_name
return ""
def _output_path_for_scene(scene_path):
scene_path = (scene_path or "").strip()
if not scene_path:
return ""
path = Path(scene_path)
if path.suffix.lower() == ".fcstd":
return str(path.with_name("3d_to_2d.json"))
if path.is_dir():
return str(path / "3d_to_2d.json")
if path.name.lower().endswith(".fcstd"):
return str(path.with_name("3d_to_2d.json"))
return str(path.parent / "3d_to_2d.json")
def _format_timestamp():
return datetime.now().astimezone().isoformat(timespec="seconds")
def _collect_instance_bindings(doc):
bindings = []
seen = set()
for device_group in _iter_device_groups(doc):
element_uuid = getattr(device_group, "QetElementUuid", "").strip()
instance_id = getattr(device_group, "QetInstanceId", "").strip()
if not element_uuid or not instance_id:
continue
key = (element_uuid, instance_id)
if key in seen:
continue
seen.add(key)
bindings.append(
{
"element_uuid": element_uuid,
"instance_id": instance_id,
}
)
return bindings
def _collect_terminal_bindings(doc):
bindings = []
seen = set()
for device_group in _iter_device_groups(doc):
instance_id = getattr(device_group, "QetInstanceId", "").strip()
for terminal_obj in _iter_terminal_objects(device_group):
terminal_uuid = getattr(terminal_obj, "QetTerminalUuid", "").strip()
terminal_instance_id = getattr(terminal_obj, "QetInstanceId", "").strip() or instance_id
if not terminal_uuid or not terminal_instance_id:
continue
key = (terminal_uuid, terminal_instance_id)
if key in seen:
continue
seen.add(key)
bindings.append(
{
"terminal_uuid": terminal_uuid,
"instance_id": terminal_instance_id,
}
)
return bindings
def _project_uuid_from_doc(doc, payload=None):
root = _root_group(doc)
if root is not None:
project_uuid = getattr(root, "QetProjectUuid", "").strip()
if project_uuid:
return project_uuid
return _project_uuid_from_payload(payload)
def write_back_document(doc=None, scene_path="", payload=None):
if doc is None:
doc = App.ActiveDocument
if doc is None:
raise ExchangeWriteBackError("No active FreeCAD document is available.")
scene_path = _scene_path_from_doc(doc, scene_path)
output_path = _output_path_for_scene(scene_path)
if not output_path:
raise ExchangeWriteBackError(
"Cannot determine the 3d_to_2d.json output path."
)
project_uuid = _project_uuid_from_doc(doc, payload)
if not project_uuid:
raise ExchangeWriteBackError(
"Cannot determine project_uuid for write-back."
)
report = {
"schema_version": "1.0",
"project_uuid": project_uuid,
"generated_at": _format_timestamp(),
"instances": _collect_instance_bindings(doc),
"terminals": _collect_terminal_bindings(doc),
"output_path": output_path,
}
output_dir = str(Path(output_path).parent)
os.makedirs(output_dir, exist_ok=True)
Path(output_path).write_text(
json.dumps(
{
"schema_version": report["schema_version"],
"project_uuid": report["project_uuid"],
"generated_at": report["generated_at"],
"instances": report["instances"],
"terminals": report["terminals"],
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
_append_debug_log(
"write_back_document completed: instances={0}, terminals={1}, path={2}".format(
len(report["instances"]),
len(report["terminals"]),
output_path,
)
)
try:
App.Console.PrintMessage(
"[FreeCADExchange] Wrote 3d_to_2d.json to {0}\n".format(output_path)
)
except Exception:
pass
return report
def _is_exchange_document(doc):
if doc is None:
return False
if _root_group(doc) is not None:
return True
for obj in doc.Objects:
if _is_device_group(obj):
return True
return False
class _WriteBackObserver:
def slotFinishSaveDocument(self, doc, name):
if not _is_exchange_document(doc):
return
try:
write_back_document(doc, scene_path=name)
except Exception as exc:
_append_debug_log("write-back after save failed: {0}".format(exc))
try:
App.Console.PrintError(
"[FreeCADExchange] write-back after save failed: {0}\n".format(exc)
)
except Exception:
pass
def ensure_document_observer_installed():
if getattr(App, STATE_WRITEBACK_OBSERVER, None) is not None:
return getattr(App, STATE_WRITEBACK_OBSERVER)
observer = _WriteBackObserver()
try:
App.addDocumentObserver(observer)
except Exception as exc:
_append_debug_log("failed to add write-back observer: {0}".format(exc))
return None
setattr(App, STATE_WRITEBACK_OBSERVER, observer)
return observer
class CommandWriteBack:
def GetResources(self):
return {
"MenuText": "Write Back 3D Binding",
"ToolTip": "Generate 3d_to_2d.json from the current FreeCAD document",
}
def IsActive(self):
return App.ActiveDocument is not None
def Activated(self):
try:
report = write_back_document(App.ActiveDocument)
try:
App.Console.PrintMessage(
"[FreeCADExchange] Write-back completed: {0} instances, {1} terminals\n".format(
len(report["instances"]),
len(report["terminals"]),
)
)
except Exception:
pass
except Exception as exc:
try:
App.Console.PrintError(
"[FreeCADExchange] Write-back failed: {0}\n".format(exc)
)
except Exception:
pass
_COMMANDS_REGISTERED = False
def register_commands():
global _COMMANDS_REGISTERED
if _COMMANDS_REGISTERED:
return
if Gui is None:
return
try:
Gui.addCommand("QET_Exchange_WriteBack", CommandWriteBack())
_COMMANDS_REGISTERED = True
except Exception as exc:
_append_debug_log("failed to register write-back command: {0}".format(exc))
register_commands()
ensure_document_observer_installed()

@ -2,33 +2,212 @@
import os import os
from pathlib import Path from pathlib import Path
import traceback
import FreeCADGui as Gui
try:
from PySide6 import QtCore
except ImportError:
try:
from PySide2 import QtCore
except ImportError:
from PySide import QtCore
import ExchangeBootstrap COMMANDS = [
"QET_Template_OpenAuthoringPanel",
"QET_Template_AddTerminal",
"QET_Template_ValidateTerminals",
"QET_Template_SaveAsFCStd",
]
def _append_init_log(message): def _append_init_log(message, os_module=os, path_class=Path):
try: try:
local_app_data = os.environ.get("LOCALAPPDATA", "").strip() local_app_data = os_module.environ.get("LOCALAPPDATA", "").strip()
if local_app_data: if local_app_data:
log_path = os.path.join(local_app_data, "QETDeps", "freecad_exchange_bootstrap.log") log_path = os_module.path.join(local_app_data, "QETDeps", "freecad_exchange_bootstrap.log")
else: else:
log_path = os.path.join(str(Path.home()), "AppData", "Local", "QETDeps", "freecad_exchange_bootstrap.log") log_path = os_module.path.join(
os.makedirs(os.path.dirname(log_path), exist_ok=True) str(path_class.home()),
"AppData",
"Local",
"QETDeps",
"freecad_exchange_bootstrap.log",
)
os_module.makedirs(os_module.path.dirname(log_path), exist_ok=True)
with open(log_path, "a", encoding="utf-8") as handle: with open(log_path, "a", encoding="utf-8") as handle:
handle.write(message + "\n") handle.write(message + "\n")
except Exception: except Exception:
pass pass
_append_init_log("InitGui imported") _append_init_log("InitGui start")
def _safe_import(module_name, append_init_log=_append_init_log, traceback_module=traceback):
try:
module = __import__(module_name)
append_init_log("InitGui imported {0}".format(module_name))
return module
except Exception:
append_init_log(
"InitGui failed to import {0}:\n{1}".format(
module_name,
traceback_module.format_exc(),
)
)
return None
def _register_exchange_commands(
safe_import=_safe_import,
append_init_log=_append_init_log,
traceback_module=traceback,
):
exchange_write_back = safe_import("ExchangeWriteBack")
manual_wiring = safe_import("ManualWiring")
template_authoring = safe_import("TemplateAuthoring")
template_authoring_panel = safe_import("TemplateAuthoringPanel")
try:
if exchange_write_back is not None:
exchange_write_back.ensure_document_observer_installed()
except Exception:
append_init_log(
"InitGui failed to install write-back observer:\n{0}".format(
traceback_module.format_exc()
)
)
try:
if exchange_write_back is not None:
exchange_write_back.register_commands()
except Exception:
append_init_log(
"InitGui failed to register write-back commands:\n{0}".format(
traceback_module.format_exc()
)
)
try:
if manual_wiring is not None:
manual_wiring.register_commands()
except Exception:
append_init_log(
"InitGui failed to register wiring commands:\n{0}".format(
traceback_module.format_exc()
)
)
try:
if template_authoring is not None:
template_authoring.register_commands()
except Exception:
append_init_log(
"InitGui failed to register template authoring commands:\n{0}".format(
traceback_module.format_exc()
)
)
try:
if template_authoring_panel is not None:
template_authoring_panel.register_commands()
except Exception:
append_init_log(
"InitGui failed to register template panel command:\n{0}".format(
traceback_module.format_exc()
)
)
def _bootstrap_if_requested(
safe_import=_safe_import,
append_init_log=_append_init_log,
traceback_module=traceback,
):
exchange_bootstrap = safe_import("ExchangeBootstrap")
if exchange_bootstrap is None:
return
try:
exchange_bootstrap.bootstrap_if_requested()
except Exception:
append_init_log(
"InitGui bootstrap_if_requested failed:\n{0}".format(
traceback_module.format_exc()
)
)
globals()["FreeCADExchange_COMMANDS"] = COMMANDS
globals()["FreeCADExchange_append_init_log"] = _append_init_log
globals()["FreeCADExchange_register_exchange_commands"] = _register_exchange_commands
globals()["FreeCADExchange_bootstrap_if_requested"] = _bootstrap_if_requested
class FreeCADExchangeWorkbench(Gui.Workbench):
MenuText = "QET模板"
ToolTip = "QET / FreeCAD 设备模板端子制作工具"
Icon = """
/* XPM */
static const char *qet_template_xpm[]={
"16 16 3 1",
"a c #1f6feb",
"b c #ffffff",
". c None",
"................",
"...aaaaaaaaaa...",
"..abbbbbbbbba..",
"..abaaaaaabba..",
"..ababbbaabba..",
"..ababbbaabba..",
"..abaaaaaabba..",
"..abbbbbbbbba..",
"..abbaaabbbba..",
"..abbaabababa..",
"..abbaabababa..",
"..abbaaabbbba..",
"..abbbbbbbbba..",
"...aaaaaaaaaa...",
"................",
"................"};
"""
def Initialize(
self,
register_exchange_commands=FreeCADExchange_register_exchange_commands,
append_init_log=FreeCADExchange_append_init_log,
commands=FreeCADExchange_COMMANDS,
):
register_exchange_commands()
self.appendToolbar("QET模板", commands)
self.appendMenu("QET模板", commands)
append_init_log("FreeCADExchangeWorkbench initialized")
def Activated(
self,
register_exchange_commands=FreeCADExchange_register_exchange_commands,
append_init_log=FreeCADExchange_append_init_log,
):
register_exchange_commands()
append_init_log("FreeCADExchangeWorkbench activated")
def Deactivated(self):
pass
def GetClassName(self):
return "Gui::PythonWorkbench"
Gui.addWorkbench(FreeCADExchangeWorkbench())
_append_init_log("InitGui workbench registered")
try:
from PySide6 import QtCore
except ImportError:
try:
from PySide2 import QtCore
except ImportError:
try:
from PySide import QtCore
except ImportError:
QtCore = None
QtCore.QTimer.singleShot(0, ExchangeBootstrap.bootstrap_if_requested) if QtCore is not None:
QtCore.QTimer.singleShot(0, FreeCADExchange_bootstrap_if_requested)
else:
FreeCADExchange_bootstrap_if_requested()

@ -0,0 +1,236 @@
# FreeCADExchange manual wiring helpers.
import FreeCAD as App
try:
import FreeCADGui as Gui
except ImportError:
Gui = None
import DeviceImport
import TerminalObjects as TerminalObjects
class ManualWiringError(RuntimeError):
pass
def _append_debug_log(message):
try:
DeviceImport._append_debug_log(message)
except Exception:
pass
def _terminal_points(start_terminal, end_terminal, waypoints=None):
points = [TerminalObjects.terminal_origin(start_terminal)]
for point in waypoints or []:
if isinstance(point, App.Vector):
points.append(point)
elif isinstance(point, (list, tuple)) and len(point) >= 3:
points.append(App.Vector(float(point[0]), float(point[1]), float(point[2])))
points.append(TerminalObjects.terminal_origin(end_terminal))
return points
def _wire_object_name(start_terminal, end_terminal):
start_uuid = TerminalObjects.safe_token(getattr(start_terminal, "QetTerminalUuid", ""))
end_uuid = TerminalObjects.safe_token(getattr(end_terminal, "QetTerminalUuid", ""))
return "QETWire_{0}_{1}".format(start_uuid, end_uuid)
def _set_wire_properties(obj, project_uuid, start_terminal, end_terminal):
TerminalObjects.ensure_string_property(
obj,
"QetProjectUuid",
"QET Exchange",
"Project UUID for this wire",
project_uuid,
)
TerminalObjects.ensure_string_property(
obj,
"QetStartTerminalUuid",
"QET Exchange",
"Start terminal UUID",
getattr(start_terminal, "QetTerminalUuid", "").strip(),
)
TerminalObjects.ensure_string_property(
obj,
"QetEndTerminalUuid",
"QET Exchange",
"End terminal UUID",
getattr(end_terminal, "QetTerminalUuid", "").strip(),
)
TerminalObjects.ensure_string_property(
obj,
"QetStartInstanceId",
"QET Exchange",
"Start device instance ID",
getattr(start_terminal, "QetInstanceId", "").strip(),
)
TerminalObjects.ensure_string_property(
obj,
"QetEndInstanceId",
"QET Exchange",
"End device instance ID",
getattr(end_terminal, "QetInstanceId", "").strip(),
)
TerminalObjects.ensure_string_property(
obj,
"RouteType",
"QET Exchange",
"Wire route type",
"Manual",
)
def _wire_parent_group(doc, project_uuid, start_terminal, end_terminal, fallback_group=None):
for terminal in (start_terminal, end_terminal):
element_uuid = getattr(terminal, "QetElementUuid", "").strip()
if not element_uuid:
continue
device_group = TerminalObjects.find_device_group(doc, element_uuid)
if device_group is None:
continue
device_instance_id = getattr(device_group, "QetInstanceId", "").strip()
return TerminalObjects.ensure_wire_group(
doc,
device_group,
project_uuid=project_uuid,
instance_id=device_instance_id,
)
return fallback_group
def create_manual_wire(doc, start_terminal, end_terminal, waypoints=None, parent_group=None):
if not TerminalObjects.is_terminal_object(start_terminal):
raise ManualWiringError("The start selection is not a valid terminal.")
if not TerminalObjects.is_terminal_object(end_terminal):
raise ManualWiringError("The end selection is not a valid terminal.")
if start_terminal == end_terminal:
raise ManualWiringError("The start and end terminal must be different.")
project_uuid = (
getattr(start_terminal, "QetProjectUuid", "").strip()
or getattr(end_terminal, "QetProjectUuid", "").strip()
or getattr(DeviceImport._ensure_root_group(doc), "QetProjectUuid", "").strip()
)
if not project_uuid:
raise ManualWiringError("A project UUID is required to create a wire.")
wire_base_name = TerminalObjects.safe_token(
_wire_object_name(start_terminal, end_terminal)
)
wire_name = wire_base_name
suffix = 1
while doc.getObject(wire_name) is not None:
wire_name = "{0}_{1}".format(wire_base_name, suffix)
suffix += 1
wire_obj = doc.addObject("Part::Feature", wire_name)
wire_obj.Label = "QET Manual Wire"
points = _terminal_points(start_terminal, end_terminal, waypoints=waypoints)
if len(points) < 2:
raise ManualWiringError("A wire requires at least two points.")
import Part
wire_obj.Shape = Part.makePolygon(points)
_set_wire_properties(
wire_obj,
project_uuid,
start_terminal,
end_terminal,
)
if parent_group is None:
try:
parent_group = _wire_parent_group(
doc,
project_uuid,
start_terminal,
end_terminal,
fallback_group=DeviceImport._ensure_root_group(doc, project_uuid),
)
except Exception:
parent_group = None
if parent_group is not None and wire_obj not in getattr(parent_group, "Group", []):
parent_group.addObject(wire_obj)
try:
wire_obj.ViewObject.LineWidth = 2.0
except Exception:
pass
try:
wire_obj.ViewObject.LineColor = (0.0, 0.6, 1.0)
except Exception:
pass
doc.recompute()
return wire_obj
class CommandCreateManualWire:
def GetResources(self):
return {
"MenuText": "Create Manual Wire",
"ToolTip": "Create a manual wire between two selected terminals",
}
def IsActive(self):
return App.ActiveDocument is not None and Gui is not None
def Activated(self):
if Gui is None:
return
selection = [
obj
for obj in Gui.Selection.getSelection()
if TerminalObjects.is_terminal_object(obj)
]
if len(selection) != 2:
try:
App.Console.PrintWarning(
"Select exactly two valid terminals before creating a wire.\n"
)
except Exception:
pass
return
try:
create_manual_wire(App.ActiveDocument, selection[0], selection[1])
try:
Gui.SendMsgToActiveView("ViewFit")
except Exception:
pass
except Exception as exc:
try:
App.Console.PrintError(
"[FreeCADExchange] manual wire creation failed: {0}\n".format(exc)
)
except Exception:
pass
_COMMANDS_REGISTERED = False
def register_commands():
global _COMMANDS_REGISTERED
if _COMMANDS_REGISTERED:
return
if Gui is None:
return
try:
Gui.addCommand("QET_Exchange_CreateManualWire", CommandCreateManualWire())
_COMMANDS_REGISTERED = True
except Exception as exc:
_append_debug_log("failed to register manual wiring command: {0}".format(exc))
register_commands()

@ -0,0 +1,332 @@
# FreeCADExchange FCStd template authoring helpers.
import FreeCAD as App
try:
import FreeCADGui as Gui
except ImportError:
Gui = None
try:
from PySide6 import QtWidgets
except ImportError:
try:
from PySide2 import QtWidgets
except ImportError:
try:
from PySide import QtGui as QtWidgets
except ImportError:
QtWidgets = 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 _fcstd_path(path):
value = (path or "").strip()
if not value:
raise TemplateAuthoringError("A target FCStd path is required.")
if not value.lower().endswith(".fcstd"):
value = value + ".FCStd"
return value
def save_template_as_fcstd(doc, path):
if doc is None:
raise TemplateAuthoringError("An active FreeCAD document is required.")
target_path = _fcstd_path(path)
report = validate_template_terminals(doc)
if report["total_terminals"] <= 0:
raise TemplateAuthoringError("At least one template terminal is required before saving.")
if report["warnings"]:
raise TemplateAuthoringError(
"Template terminals must be valid before saving: {0}".format(
"; ".join(report["warnings"])
)
)
doc.saveAs(target_path)
report["path"] = target_path
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": "添加模板端子",
"ToolTip": "在 FCStd 设备模板中创建可接线端子 LCS",
}
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": "校验模板端子",
"ToolTip": "校验当前 FCStd 模板中的电气端子 LCS",
}
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))
class CommandSaveTemplateAsFCStd:
def GetResources(self):
return {
"MenuText": "保存模板为 FCStd",
"ToolTip": "校验并保存当前文档为可复用 FCStd 设备模板",
}
def IsActive(self):
return App.ActiveDocument is not None
def Activated(self):
if QtWidgets is None:
App.Console.PrintError("[FreeCADExchange] Qt file dialog is not available.\n")
return
file_path, _selected_filter = QtWidgets.QFileDialog.getSaveFileName(
None,
"保存 FCStd 设备模板",
"",
"FreeCAD template (*.FCStd *.fcstd);;All files (*.*)",
)
if not file_path:
return
try:
report = save_template_as_fcstd(App.ActiveDocument, file_path)
App.Console.PrintMessage(
"[FreeCADExchange] Saved FCStd template: {0} ({1} terminals)\n".format(
report["path"],
report["valid_terminals"],
)
)
except Exception as exc:
App.Console.PrintError(
"[FreeCADExchange] template save failed: {0}\n".format(exc)
)
_COMMANDS_REGISTERED = False
def register_commands():
global _COMMANDS_REGISTERED
if _COMMANDS_REGISTERED:
return
if Gui is None or not hasattr(Gui, "addCommand"):
return
Gui.addCommand("QET_Template_AddTerminal", CommandAddTemplateTerminal())
Gui.addCommand("QET_Template_ValidateTerminals", CommandValidateTemplateTerminals())
Gui.addCommand("QET_Template_SaveAsFCStd", CommandSaveTemplateAsFCStd())
_COMMANDS_REGISTERED = True
register_commands()

@ -0,0 +1,330 @@
# 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"
TOOLBAR_OBJECT_NAME = "QET_Template_Authoring_Toolbar"
TOOLBAR_ACTION_OBJECT_NAME = "QET_Template_OpenAuthoringPanel_ToolbarAction"
TERMINAL_TYPE_OPTIONS = [
("通用", "generic"),
("主回路", "primary"),
("电源", "power"),
("控制", "control"),
]
def terminal_type_value(combo):
value = None
if hasattr(combo, "currentData"):
value = combo.currentData()
if value:
return str(value).strip()
text = combo.currentText().strip() if hasattr(combo, "currentText") else ""
for label, option_value in TERMINAL_TYPE_OPTIONS:
if text == label or text == option_value:
return option_value
return "generic"
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()
for label, value in TERMINAL_TYPE_OPTIONS:
self.terminal_type_combo.addItem(label, value)
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 = terminal_type_value(self.terminal_type_combo)
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
_TOOLBAR_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()
text = action.text().replace("&", "").strip()
if menu is not None and (text.startswith("工具") or text.startswith("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 install_toolbar_action():
global _TOOLBAR_ACTION_INSTALLED
if _TOOLBAR_ACTION_INSTALLED:
return
if Gui is None or QtGui is None or QtWidgets is None or not hasattr(Gui, "getMainWindow"):
return
main_window = Gui.getMainWindow()
if main_window is None:
return
toolbar = None
for candidate in main_window.findChildren(QtWidgets.QToolBar):
if candidate.objectName() == TOOLBAR_OBJECT_NAME:
toolbar = candidate
break
if toolbar is None:
toolbar = main_window.addToolBar("QET模板")
toolbar.setObjectName(TOOLBAR_OBJECT_NAME)
for action in toolbar.actions():
if action.objectName() == TOOLBAR_ACTION_OBJECT_NAME:
_TOOLBAR_ACTION_INSTALLED = True
return
action = QtGui.QAction("设备模板端子制作", toolbar)
action.setObjectName(TOOLBAR_ACTION_OBJECT_NAME)
action.setToolTip("打开设备模板端子制作面板")
action.triggered.connect(lambda: Gui.runCommand(COMMAND_NAME))
toolbar.addAction(action)
_TOOLBAR_ACTION_INSTALLED = True
def _warn_register_commands_failed(target, exc):
try:
App.Console.PrintWarning(
"[FreeCADExchange] {0} installation skipped: {1}\n".format(target, exc)
)
except Exception:
pass
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
try:
install_menu_action()
except RuntimeError as exc:
_warn_register_commands_failed("menu action", exc)
try:
install_toolbar_action()
except RuntimeError as exc:
_warn_register_commands_failed("toolbar action", exc)

@ -0,0 +1,363 @@
# FreeCADExchange template semantics helpers.
import json
from pathlib import Path
import FreeCAD as App
import TerminalObjects as TerminalObjects
def _sidecar_candidates(model_path):
native = TerminalObjects.native_path(model_path)
if not native:
return []
path = Path(native)
base = path.stem
parent = path.parent
return [
parent / (base + ".terminals.json"),
parent / (base + ".qet_terminals.json"),
parent / (base + ".terminal_slots.json"),
parent / (base + ".qet_template.json"),
]
def _load_json_file(path):
try:
raw_text = path.read_text(encoding="utf-8")
except OSError:
return None
try:
payload = json.loads(raw_text)
except json.JSONDecodeError:
return None
if not isinstance(payload, dict):
return None
return payload
def _vector_from_value(value):
if isinstance(value, App.Vector):
return value
if isinstance(value, dict):
if {"x", "y", "z"}.issubset(value.keys()):
return App.Vector(
float(value["x"]),
float(value["y"]),
float(value["z"]),
)
if isinstance(value, (list, tuple)) and len(value) >= 3:
return App.Vector(float(value[0]), float(value[1]), float(value[2]))
return None
def _rotation_from_value(value):
if not isinstance(value, dict):
return None
axis = _vector_from_value(value.get("axis"))
angle = value.get("angle")
if axis is None or angle is None:
return None
try:
angle = float(angle)
except (TypeError, ValueError):
return None
return {
"axis": axis,
"angle": angle,
}
def _rotation_from_object(source_object):
if source_object is None:
return None
rotation = None
placement = getattr(source_object, "Placement", None)
if placement is not None:
rotation = getattr(placement, "Rotation", None)
if rotation is None:
rotation = getattr(source_object, "Rotation", None)
if rotation is None:
return None
axis = getattr(rotation, "Axis", None)
if axis is None:
axis = getattr(rotation, "axis", None)
angle = getattr(rotation, "Angle", None)
if angle is None:
angle = getattr(rotation, "angle", None)
axis = _vector_from_value(axis)
if axis is None or angle is None:
return None
try:
angle = float(angle)
except (TypeError, ValueError):
return None
return {
"axis": axis,
"angle": angle,
}
def _slot_from_payload(item, source, index, source_object=None):
if not isinstance(item, dict):
return None
name = (item.get("name") or item.get("slot_name") or item.get("label") or "").strip()
if not name:
name = "SLOT_{0}".format(index + 1)
label = (item.get("label") or item.get("display_tag") or name).strip()
base = None
placement = item.get("placement")
if isinstance(placement, dict):
base = _vector_from_value(placement.get("base"))
if base is None:
base = _vector_from_value(item.get("base"))
if base is None:
base = _vector_from_value(item.get("position"))
if base is None:
base = _vector_from_value(item.get("origin"))
if base is None:
base = _vector_from_value(item.get("point"))
if base is None:
x = item.get("x")
y = item.get("y")
z = item.get("z")
if x is not None and y is not None and z is not None:
base = App.Vector(float(x), float(y), float(z))
if base is None:
return None
rotation = None
placement_rotation = None
placement = item.get("placement")
if isinstance(placement, dict):
placement_rotation = _rotation_from_value(placement.get("rotation"))
rotation = _rotation_from_value(item.get("rotation"))
if rotation is None:
rotation = placement_rotation
if rotation is None:
rotation = _rotation_from_object(source_object)
slot = {
"name": name,
"label": label,
"base": base,
"source": source,
"source_object": source_object,
}
if rotation is not None:
slot["rotation"] = rotation
return slot
def _is_child_group(obj, group_kind):
try:
return (
obj is not None
and obj.isDerivedFrom("App::DocumentObjectGroup")
and getattr(obj, "QetGroupKind", "").strip() == group_kind
)
except Exception:
return False
def collect_terminal_hints(container):
hints = []
if container is None:
return hints
for child in list(getattr(container, "Group", []) or []):
if TerminalObjects.is_terminal_hint_object(child) and not TerminalObjects.is_terminal_object(child):
hint = _slot_from_payload(
{
"name": getattr(child, "Name", ""),
"label": getattr(child, "Label", "") or getattr(child, "Name", ""),
"base": [
getattr(child.Placement.Base, "x", 0.0),
getattr(child.Placement.Base, "y", 0.0),
getattr(child.Placement.Base, "z", 0.0),
],
},
"model",
len(hints),
source_object=child,
)
if hint is not None:
hints.append(hint)
continue
if _is_child_group(child, TerminalObjects.TERMINAL_GROUP_KIND):
continue
if _is_child_group(child, TerminalObjects.WIRE_GROUP_KIND):
continue
if hasattr(child, "Group"):
hints.extend(collect_terminal_hints(child))
hints.sort(key=lambda item: (item.get("label", ""), item.get("name", "")))
return hints
def collect_model_bounding_box(container):
boxes = []
def walk(obj):
if obj is None:
return
if TerminalObjects.is_terminal_hint_object(obj):
return
if _is_child_group(obj, TerminalObjects.TERMINAL_GROUP_KIND):
return
if _is_child_group(obj, TerminalObjects.WIRE_GROUP_KIND):
return
shape = getattr(obj, "Shape", None)
if shape is not None:
try:
if not shape.isNull():
boxes.append(shape.BoundBox)
except Exception:
pass
for child in list(getattr(obj, "Group", []) or []):
walk(child)
walk(container)
if not boxes:
return None
x_min = min(box.XMin for box in boxes)
y_min = min(box.YMin for box in boxes)
z_min = min(box.ZMin for box in boxes)
x_max = max(box.XMax for box in boxes)
y_max = max(box.YMax for box in boxes)
z_max = max(box.ZMax for box in boxes)
return {
"x_min": x_min,
"y_min": y_min,
"z_min": z_min,
"x_max": x_max,
"y_max": y_max,
"z_max": z_max,
"x_span": x_max - x_min,
"y_span": y_max - y_min,
"z_span": z_max - z_min,
"x_center": (x_min + x_max) * 0.5,
"y_center": (y_min + y_max) * 0.5,
"z_center": (z_min + z_max) * 0.5,
}
def _fallback_slot_base(bbox, index, count):
if bbox is None:
step = 8.0
return App.Vector(20.0, float(index) * step, 0.0)
span_x = max(bbox["x_span"], 1.0)
span_y = max(bbox["y_span"], 1.0)
span_z = max(bbox["z_span"], 1.0)
offset = max(8.0, max(span_x, span_y, span_z) * 0.15)
if count <= 1:
y_pos = bbox["y_center"]
z_pos = bbox["z_center"]
else:
if span_y >= span_z:
step = span_y / float(count + 1)
y_pos = bbox["y_min"] + step * float(index + 1)
z_pos = bbox["z_center"]
else:
step = span_z / float(count + 1)
y_pos = bbox["y_center"]
z_pos = bbox["z_min"] + step * float(index + 1)
return App.Vector(bbox["x_max"] + offset, y_pos, z_pos)
def build_fallback_terminal_slots(container, count):
bbox = collect_model_bounding_box(container)
slots = []
for index in range(max(0, count)):
slots.append(
{
"name": "SLOT_{0}".format(index + 1),
"label": "SLOT_{0}".format(index + 1),
"base": _fallback_slot_base(bbox, index, count),
"source": "fallback",
"source_object": None,
}
)
return slots
def load_sidecar_terminal_slots(model_path):
for sidecar_path in _sidecar_candidates(model_path):
if not sidecar_path.is_file():
continue
payload = _load_json_file(sidecar_path)
if payload is None:
continue
entries = payload.get("terminal_slots")
if entries is None:
entries = payload.get("terminals")
if not isinstance(entries, list):
continue
slots = []
for index, item in enumerate(entries):
slot = _slot_from_payload(item, "sidecar", index)
if slot is not None:
slots.append(slot)
if slots:
slots.sort(key=lambda item: (item.get("label", ""), item.get("name", "")))
return slots
return []
def resolve_terminal_slots(device_group, model_path, desired_count):
desired_count = max(0, int(desired_count or 0))
hints = collect_terminal_hints(device_group)
sidecar_slots = load_sidecar_terminal_slots(model_path)
slots = []
slots.extend(hints[:desired_count])
if len(slots) < desired_count and sidecar_slots:
for slot in sidecar_slots:
if len(slots) >= desired_count:
break
slots.append(slot)
if len(slots) < desired_count:
fallback_slots = build_fallback_terminal_slots(device_group, desired_count)
for slot in fallback_slots:
if len(slots) >= desired_count:
break
slots.append(slot)
return slots[:desired_count]

@ -0,0 +1,325 @@
# FreeCADExchange terminal import helpers.
from collections import OrderedDict
import FreeCAD as App
import FreeCADGui as Gui
import DeviceImport
import TerminalObjects as TerminalObjects
import TemplateSemantics
class TerminalImportError(RuntimeError):
pass
def _append_debug_log(message):
try:
DeviceImport._append_debug_log(message)
except Exception:
pass
def _normalize_terminal_entry(item, index):
if not isinstance(item, dict):
raise TerminalImportError(
"Terminal entry #{0} must be an object.".format(index)
)
terminal_uuid = (item.get("terminal_uuid") or "").strip()
if not terminal_uuid:
raise TerminalImportError(
"Terminal entry #{0} is missing terminal_uuid.".format(index)
)
instance_id = (item.get("instance_id") or "").strip()
element_uuid = (item.get("element_uuid") or "").strip()
return {
"terminal_uuid": terminal_uuid,
"instance_id": instance_id,
"element_uuid": element_uuid,
}
def _ensure_visible(obj):
try:
if getattr(obj, "ViewObject", None) is not None:
obj.ViewObject.Visibility = True
except Exception:
pass
def _hide_object(obj):
try:
if getattr(obj, "ViewObject", None) is not None:
obj.ViewObject.Visibility = False
except Exception:
pass
def _terminal_existing_index(container):
index = OrderedDict()
for obj in TerminalObjects.collect_terminal_objects(container):
terminal_uuid = getattr(obj, "QetTerminalUuid", "").strip()
if terminal_uuid and terminal_uuid not in index:
index[terminal_uuid] = obj
return index
def _device_key(device_group):
return getattr(device_group, "QetInstanceId", "").strip() or getattr(
device_group, "QetElementUuid", ""
).strip()
def _locate_device_group(doc, entry):
instance_id = entry["instance_id"]
element_uuid = entry["element_uuid"]
device_group = None
if instance_id:
device_group = TerminalObjects.find_device_group_by_instance_id(doc, instance_id)
if device_group is None and element_uuid:
device_group = DeviceImport._find_device_group(doc, element_uuid)
return device_group
def _terminal_container_for_device(doc, device_group, project_uuid):
device_instance_id = getattr(device_group, "QetInstanceId", "").strip()
return TerminalObjects.ensure_terminal_group(
doc,
device_group,
project_uuid=project_uuid,
instance_id=device_instance_id,
)
def _is_device_group(obj):
try:
return (
obj is not None
and getattr(obj, "Name", "").startswith(DeviceImport.DEVICE_GROUP_PREFIX)
and "QetElementUuid" in getattr(obj, "PropertiesList", [])
)
except Exception:
return False
def _terminal_slot_label(slot, terminal_uuid):
label = (slot.get("label") or "").strip()
if label:
return label
return terminal_uuid
def _slot_base(slot):
base = slot.get("base")
if isinstance(base, App.Vector):
return base
return App.Vector(0, 0, 0)
def _slot_placement(slot):
base = _slot_base(slot)
rotation = App.Rotation()
rotation_value = slot.get("rotation")
if isinstance(rotation_value, dict):
axis = rotation_value.get("axis")
angle = rotation_value.get("angle")
if isinstance(axis, App.Vector) and angle is not None:
try:
rotation = App.Rotation(axis, float(angle))
except Exception:
rotation = App.Rotation()
return App.Placement(base, rotation)
def _create_terminal_object(doc, terminal_uuid, slot, terminal_group, project_uuid, element_uuid, instance_id):
name_hint = "QETTerminal_{0}".format(TerminalObjects.safe_token(terminal_uuid))
terminal_obj = TerminalObjects.create_lcs_object(
doc,
name_hint,
placement=_slot_placement(slot),
label=_terminal_slot_label(slot, terminal_uuid),
)
terminal_group.addObject(terminal_obj)
TerminalObjects.set_terminal_semantics(
terminal_obj,
project_uuid,
element_uuid,
terminal_uuid,
instance_id,
label=_terminal_slot_label(slot, terminal_uuid),
slot_name=slot.get("name", ""),
)
_ensure_visible(terminal_obj)
return terminal_obj
def import_terminals_from_payload(payload, scene_path=""):
_append_debug_log("TerminalImport.import_terminals_from_payload entered")
if not isinstance(payload, dict):
raise TerminalImportError("Exchange payload must be an object.")
project_uuid = (payload.get("project_uuid") or "").strip()
if not project_uuid:
raise TerminalImportError("Field 'project_uuid' is required for terminal import.")
doc = DeviceImport._ensure_document(scene_path)
root_group = DeviceImport._ensure_root_group(doc, project_uuid)
_ = root_group
terminal_entries = payload.get("terminals", [])
if not isinstance(terminal_entries, list):
raise TerminalImportError("Field 'terminals' must be a list.")
report = {
"document_name": doc.Name,
"scene_path": scene_path or "",
"project_uuid": project_uuid,
"total_terminals": 0,
"imported_terminals": 0,
"updated_terminals": 0,
"removed_terminals": 0,
"reused_template_hints": 0,
"skipped_missing_device": 0,
"skipped_invalid_entry": 0,
"warnings": [],
}
grouped = OrderedDict()
for index, item in enumerate(terminal_entries):
report["total_terminals"] += 1
try:
entry = _normalize_terminal_entry(item, index)
except TerminalImportError as exc:
report["skipped_invalid_entry"] += 1
report["warnings"].append(str(exc))
continue
device_group = _locate_device_group(doc, entry)
if device_group is None:
report["skipped_missing_device"] += 1
report["warnings"].append(
"Terminal {0} could not find its parent device.".format(
entry["terminal_uuid"]
)
)
continue
key = device_group.Name
if key not in grouped:
grouped[key] = {"device_group": device_group, "entries": []}
grouped[key]["entries"].append(entry)
device_candidates = []
if root_group is not None:
device_candidates.extend(list(getattr(root_group, "Group", []) or []))
if not device_candidates:
device_candidates.extend(doc.Objects)
for device_group in device_candidates:
if not _is_device_group(device_group):
continue
item = grouped.get(device_group.Name, {"entries": []})
entries = item["entries"]
device_element_uuid = getattr(device_group, "QetElementUuid", "").strip()
device_instance_id = getattr(device_group, "QetInstanceId", "").strip()
resolved_model_path = getattr(device_group, "QetResolvedModelPath", "").strip()
terminal_group = _terminal_container_for_device(doc, device_group, project_uuid)
existing_by_uuid = _terminal_existing_index(terminal_group)
used_uuids = set()
slots = TemplateSemantics.resolve_terminal_slots(
device_group,
resolved_model_path,
len(entries),
)
for index, entry in enumerate(entries):
terminal_uuid = entry["terminal_uuid"]
payload_instance_id = entry["instance_id"]
if payload_instance_id and payload_instance_id != device_instance_id:
report["warnings"].append(
"Terminal {0} references instance_id {1} but device {2} uses {3}. The device value was kept."
.format(terminal_uuid, payload_instance_id, device_element_uuid, device_instance_id)
)
slot = slots[index] if index < len(slots) else {
"name": "SLOT_{0}".format(index + 1),
"label": terminal_uuid,
"base": App.Vector(0, 0, 0),
"source": "fallback",
"source_object": None,
}
terminal_obj = existing_by_uuid.get(terminal_uuid)
if terminal_obj is None:
terminal_obj = _create_terminal_object(
doc,
terminal_uuid,
slot,
terminal_group,
project_uuid,
device_element_uuid,
device_instance_id,
)
report["imported_terminals"] += 1
else:
TerminalObjects.set_terminal_semantics(
terminal_obj,
project_uuid,
device_element_uuid,
terminal_uuid,
device_instance_id,
label=_terminal_slot_label(slot, terminal_uuid),
slot_name=slot.get("name", ""),
)
try:
terminal_obj.Placement = _slot_placement(slot)
except Exception:
pass
_ensure_visible(terminal_obj)
report["updated_terminals"] += 1
if terminal_obj not in getattr(terminal_group, "Group", []):
terminal_group.addObject(terminal_obj)
used_uuids.add(terminal_uuid)
source_obj = slot.get("source_object")
if source_obj is not None:
_hide_object(source_obj)
report["reused_template_hints"] += 1
for terminal_uuid, terminal_obj in list(existing_by_uuid.items()):
if terminal_uuid in used_uuids:
continue
report["warnings"].append(
"Removed stale terminal {0} from device {1}.".format(
terminal_uuid, device_element_uuid
)
)
TerminalObjects.remove_object_tree(doc, terminal_obj)
report["removed_terminals"] += 1
doc.recompute()
try:
Gui.SendMsgToActiveView("ViewFit")
except Exception:
pass
_append_debug_log(
"TerminalImport finished: imported={0}, updated={1}, removed={2}".format(
report["imported_terminals"],
report["updated_terminals"],
report["removed_terminals"],
)
)
return report

@ -0,0 +1,386 @@
# FreeCADExchange terminal and object helpers.
import json
import os
from pathlib import Path
import FreeCAD as App
ROOT_GROUP_NAME = "QETExchangeDevices"
ROOT_GROUP_LABEL = "QET Exchange Devices"
DEVICE_GROUP_PREFIX = "QETDevice_"
TERMINAL_GROUP_PREFIX = "QETTerminals_"
WIRE_GROUP_PREFIX = "QETWires_"
TERMINAL_GROUP_KIND = "Terminals"
WIRE_GROUP_KIND = "Wires"
TERMINAL_ROLE = "Terminal"
def safe_token(value):
text = (value or "").strip()
if not text:
return "unknown"
chars = []
for ch in text:
if ch.isalnum():
chars.append(ch)
else:
chars.append("_")
return "".join(chars)
def native_path(value):
text = (value or "").strip()
if not text:
return ""
return os.path.normpath(os.path.expandvars(os.path.expanduser(text)))
def ensure_string_property(obj, prop_name, group_name, description, value):
if prop_name not in getattr(obj, "PropertiesList", []):
obj.addProperty("App::PropertyString", prop_name, group_name, description)
setattr(obj, prop_name, value or "")
def ensure_bool_property(obj, prop_name, group_name, description, value):
if prop_name not in getattr(obj, "PropertiesList", []):
obj.addProperty("App::PropertyBool", prop_name, group_name, description)
setattr(obj, prop_name, bool(value))
def _unique_object_name(doc, base_name):
name = safe_token(base_name) or "QETObject"
if doc.getObject(name) is None:
return name
suffix = 1
while doc.getObject("{0}_{1}".format(name, suffix)) is not None:
suffix += 1
return "{0}_{1}".format(name, suffix)
def _group_kind(obj):
return (getattr(obj, "QetGroupKind", "") or "").strip()
def _is_group_candidate(obj):
try:
return bool(obj and obj.isDerivedFrom("App::DocumentObjectGroup"))
except Exception:
return False
def ensure_root_group(doc, project_uuid=""):
root = doc.getObject(ROOT_GROUP_NAME)
if root is None:
root = doc.addObject("App::DocumentObjectGroup", ROOT_GROUP_NAME)
root.Label = ROOT_GROUP_LABEL
project_uuid = (project_uuid or "").strip() or getattr(root, "QetProjectUuid", "").strip()
ensure_string_property(
root,
"QetProjectUuid",
"QET Exchange",
"Project UUID for the exchange document",
project_uuid,
)
return root
def ensure_named_child_group(
doc,
parent_group,
name_prefix,
label,
group_kind,
project_uuid="",
element_uuid="",
instance_id="",
):
target_element_uuid = (element_uuid or "").strip()
preferred_name = name_prefix + safe_token(target_element_uuid)
group = doc.getObject(preferred_name)
if group is None and _is_group_candidate(parent_group):
for candidate in getattr(parent_group, "Group", []) or []:
if _group_kind(candidate) != group_kind:
continue
if target_element_uuid and getattr(candidate, "QetElementUuid", "").strip() != target_element_uuid:
continue
group = candidate
break
if group is None:
group = doc.addObject(
"App::DocumentObjectGroup",
_unique_object_name(doc, preferred_name),
)
if parent_group is not None and group not in getattr(parent_group, "Group", []):
parent_group.addObject(group)
group.Label = label
project_uuid = (project_uuid or "").strip() or getattr(group, "QetProjectUuid", "").strip()
element_uuid = (element_uuid or "").strip() or getattr(group, "QetElementUuid", "").strip()
instance_id = (instance_id or "").strip() or getattr(group, "QetInstanceId", "").strip()
ensure_string_property(
group,
"QetGroupKind",
"QET Exchange",
"FreeCADExchange group kind",
group_kind,
)
ensure_string_property(
group,
"QetProjectUuid",
"QET Exchange",
"Project UUID for the exchange document",
project_uuid,
)
ensure_string_property(
group,
"QetElementUuid",
"QET Exchange",
"Parent element UUID for the exchange group",
element_uuid,
)
ensure_string_property(
group,
"QetInstanceId",
"QET Exchange",
"Parent instance UUID for the exchange group",
instance_id,
)
return group
def ensure_terminal_group(doc, device_group, project_uuid="", instance_id=""):
element_uuid = getattr(device_group, "QetElementUuid", "").strip()
label = "QET Terminals"
return ensure_named_child_group(
doc,
device_group,
TERMINAL_GROUP_PREFIX,
label,
TERMINAL_GROUP_KIND,
project_uuid=project_uuid,
element_uuid=element_uuid,
instance_id=instance_id,
)
def ensure_wire_group(doc, device_group, project_uuid="", instance_id=""):
element_uuid = getattr(device_group, "QetElementUuid", "").strip()
label = "QET Wires"
return ensure_named_child_group(
doc,
device_group,
WIRE_GROUP_PREFIX,
label,
WIRE_GROUP_KIND,
project_uuid=project_uuid,
element_uuid=element_uuid,
instance_id=instance_id,
)
def find_child_group_by_kind(parent_group, group_kind):
if parent_group is None:
return None
for candidate in getattr(parent_group, "Group", []) or []:
if _group_kind(candidate) == group_kind:
return candidate
return None
def find_device_group(doc, element_uuid):
target_uuid = (element_uuid or "").strip()
if not target_uuid:
return None
preferred_name = DEVICE_GROUP_PREFIX + safe_token(target_uuid)
obj = doc.getObject(preferred_name)
if obj is not None:
return obj
for candidate in doc.Objects:
if DEVICE_GROUP_PREFIX not in getattr(candidate, "Name", ""):
continue
if "QetElementUuid" in getattr(candidate, "PropertiesList", []):
if getattr(candidate, "QetElementUuid", "").strip() == target_uuid:
return candidate
return None
def find_device_group_by_instance_id(doc, instance_id):
target_instance_id = (instance_id or "").strip()
if not target_instance_id:
return None
for candidate in doc.Objects:
if "QetInstanceId" in getattr(candidate, "PropertiesList", []):
if getattr(candidate, "QetInstanceId", "").strip() == target_instance_id:
if getattr(candidate, "Name", "").startswith(DEVICE_GROUP_PREFIX):
return candidate
return None
def is_lcs_like(obj):
if obj is None:
return False
try:
if obj.isDerivedFrom("App::LocalCoordinateSystem"):
return True
except Exception:
pass
return getattr(obj, "TypeId", "") in {
"Part::LocalCoordinateSystem",
"PartDesign::CoordinateSystem",
}
def is_terminal_hint_object(obj):
if not is_lcs_like(obj):
return False
role = getattr(obj, "Role", "")
return isinstance(role, str) and role.strip() == TERMINAL_ROLE
def is_terminal_object(obj):
if not is_terminal_hint_object(obj):
return False
if "QetTerminalUuid" not in getattr(obj, "PropertiesList", []):
return False
if "CanWire" not in getattr(obj, "PropertiesList", []):
return False
return bool(getattr(obj, "CanWire", False))
def terminal_origin(obj):
try:
placement = getattr(obj, "Placement", None)
if placement is not None:
return App.Vector(placement.Base.x, placement.Base.y, placement.Base.z)
except Exception:
pass
return App.Vector(0, 0, 0)
def create_lcs_object(doc, name_hint, placement=None, label=None):
base_name = safe_token(name_hint) or "QETTerminal"
object_name = _unique_object_name(doc, base_name)
lcs = None
for type_name in ("Part::LocalCoordinateSystem", "PartDesign::CoordinateSystem"):
try:
lcs = doc.addObject(type_name, object_name)
break
except Exception:
lcs = None
if lcs is None:
raise RuntimeError("FreeCAD does not provide a usable LCS object type.")
if label:
lcs.Label = label
elif name_hint:
lcs.Label = name_hint
if placement is not None:
try:
lcs.Placement = placement
except Exception:
pass
return lcs
def set_terminal_semantics(
obj,
project_uuid,
element_uuid,
terminal_uuid,
instance_id,
label="",
slot_name="",
):
ensure_string_property(
obj,
"QetProjectUuid",
"QET Exchange",
"Project UUID for this terminal",
project_uuid,
)
ensure_string_property(
obj,
"QetElementUuid",
"QET Exchange",
"Parent element UUID for this terminal",
element_uuid,
)
ensure_string_property(
obj,
"QetTerminalUuid",
"QET Exchange",
"Terminal UUID from QET",
terminal_uuid,
)
ensure_string_property(
obj,
"QetInstanceId",
"QET Exchange",
"Parent instance UUID for this terminal",
instance_id,
)
ensure_string_property(
obj,
"Role",
"QET Exchange",
"Terminal role marker",
TERMINAL_ROLE,
)
ensure_bool_property(
obj,
"CanWire",
"QET Exchange",
"Whether the terminal can be used for wiring",
True,
)
if slot_name:
ensure_string_property(
obj,
"QetTemplateSlotName",
"QET Exchange",
"Template slot name",
slot_name,
)
terminal_label = (label or "").strip() or terminal_uuid or "QET Terminal"
obj.Label = terminal_label
return obj
def remove_object_tree(doc, obj):
if obj is None:
return
children = list(getattr(obj, "Group", []) or [])
for child in children:
remove_object_tree(doc, child)
if doc.getObject(obj.Name) is not None:
doc.removeObject(obj.Name)
def collect_terminal_objects(container):
result = []
if container is None:
return result
children = list(getattr(container, "Group", []) or [])
for child in children:
if is_terminal_object(child):
result.append(child)
continue
if _is_group_candidate(child):
result.extend(collect_terminal_objects(child))
return result

@ -72,11 +72,16 @@ void StartGui::Manipulator::modifyMenuBar(Gui::MenuItem* menuBar)
} }
Gui::MenuItem* helpMenu = menuBar->findItem("&Help"); Gui::MenuItem* helpMenu = menuBar->findItem("&Help");
if (!helpMenu) {
return;
}
Gui::MenuItem* loadStart = new Gui::MenuItem(); Gui::MenuItem* loadStart = new Gui::MenuItem();
Gui::MenuItem* loadSeparator = new Gui::MenuItem(); Gui::MenuItem* loadSeparator = new Gui::MenuItem();
loadStart->setCommand("Start_Start"); loadStart->setCommand("Start_Start");
loadSeparator->setCommand("Separator"); loadSeparator->setCommand("Separator");
Gui::MenuItem* firstItem = helpMenu->findItem("Std_FreeCADUserHub"); Gui::MenuItem* firstItem = helpMenu->findItem("Std_FreeCADUserHub");
helpMenu->insertItem(firstItem, loadStart); if (firstItem) {
helpMenu->insertItem(firstItem, loadSeparator); helpMenu->insertItem(firstItem, loadStart);
helpMenu->insertItem(firstItem, loadSeparator);
}
} }

@ -0,0 +1,192 @@
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.Console = types.SimpleNamespace(
PrintMessage=lambda *args, **kwargs: None,
PrintWarning=lambda *args, **kwargs: None,
PrintError=lambda *args, **kwargs: None,
)
fake_freecad.ActiveDocument = None
sys.modules["FreeCAD"] = fake_freecad
fake_freecadgui = types.ModuleType("FreeCADGui")
fake_freecadgui.SendMsgToActiveView = lambda *args, **kwargs: None
fake_freecadgui.addCommand = lambda *args, **kwargs: None
fake_freecadgui.Selection = types.SimpleNamespace(getSelection=lambda: [])
sys.modules["FreeCADGui"] = fake_freecadgui
fake_importgui = types.ModuleType("ImportGui")
fake_importgui.insert = lambda *args, **kwargs: None
sys.modules["ImportGui"] = fake_importgui
fake_part = types.ModuleType("Part")
fake_part.makePolygon = lambda points: tuple(points)
sys.modules["Part"] = fake_part
class FakeViewObject:
def __init__(self):
self.Visibility = True
self.LineWidth = None
self.LineColor = 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.Shape = 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.Objects = []
self.Name = "FakeDoc"
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 removeObject(self, name):
self.Objects = [obj for obj in self.Objects if obj.Name != name]
def recompute(self):
return None
def _reload_modules():
for name in [
"TerminalObjects",
"DeviceImport",
"ManualWiring",
]:
sys.modules.pop(name, None)
import DeviceImport
import ManualWiring
import TerminalObjects
return DeviceImport, ManualWiring, TerminalObjects
class ManualWiringGroupTest(unittest.TestCase):
def test_manual_wire_is_added_to_device_wire_group(self):
_install_fake_freecad()
device_import, manual_wiring, terminal_objects = _reload_modules()
doc = FakeDocument()
root = terminal_objects.ensure_root_group(doc, "project-1")
device_group = doc.addObject("App::DocumentObjectGroup", "QETDevice_device_a")
root.addObject(device_group)
terminal_objects.ensure_string_property(
device_group,
"QetElementUuid",
"QET Exchange",
"Element UUID",
"device-a",
)
terminal_objects.ensure_string_property(
device_group,
"QetInstanceId",
"QET Exchange",
"Instance ID",
"instance-a",
)
terminal_objects.ensure_string_property(
device_group,
"QetProjectUuid",
"QET Exchange",
"Project UUID",
"project-1",
)
start_terminal = FakeObject("TerminalStart", "Part::LocalCoordinateSystem")
terminal_objects.set_terminal_semantics(
start_terminal,
"project-1",
"device-a",
"terminal-start",
"instance-a",
label="Start",
)
end_terminal = FakeObject("TerminalEnd", "Part::LocalCoordinateSystem")
terminal_objects.set_terminal_semantics(
end_terminal,
"project-1",
"device-a",
"terminal-end",
"instance-a",
label="End",
)
wire = manual_wiring.create_manual_wire(doc, start_terminal, end_terminal)
wire_group = terminal_objects.find_child_group_by_kind(
device_group,
terminal_objects.WIRE_GROUP_KIND,
)
self.assertIsNotNone(wire_group)
self.assertIn(wire, wire_group.Group)
self.assertNotIn(wire, root.Group)
if __name__ == "__main__":
unittest.main()

@ -0,0 +1,112 @@
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_register_commands_ignores_menu_install_runtime_errors(self):
_install_fake_modules()
panel_module = _reload_panel_module()
panel_module._COMMANDS_REGISTERED = False
def raise_deleted_menu_error():
raise RuntimeError("Internal C++ object already deleted")
panel_module.install_menu_action = raise_deleted_menu_error
panel_module.install_toolbar_action = raise_deleted_menu_error
panel_module.register_commands()
def test_terminal_type_options_show_chinese_labels_with_stable_values(self):
_install_fake_modules()
panel_module = _reload_panel_module()
self.assertEqual(
[
("通用", "generic"),
("主回路", "primary"),
("电源", "power"),
("控制", "control"),
],
panel_module.TERMINAL_TYPE_OPTIONS,
)
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()

@ -0,0 +1,209 @@
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
def _install_fake_freecad_without_gui_commands():
_install_fake_freecad()
sys.modules["FreeCADGui"] = types.ModuleType("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
self.saved_path = ""
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 saveAs(self, path):
self.saved_path = path
def _reload_modules():
for name in ["TerminalObjects", "TemplateAuthoring"]:
sys.modules.pop(name, None)
return importlib.import_module("TemplateAuthoring")
class TemplateAuthoringTest(unittest.TestCase):
def test_template_authoring_command_titles_are_chinese(self):
_install_fake_freecad()
template_authoring = _reload_modules()
self.assertEqual(
"添加模板端子",
template_authoring.CommandAddTemplateTerminal().GetResources()["MenuText"],
)
self.assertEqual(
"校验模板端子",
template_authoring.CommandValidateTemplateTerminals().GetResources()["MenuText"],
)
self.assertEqual(
"保存模板为 FCStd",
template_authoring.CommandSaveTemplateAsFCStd().GetResources()["MenuText"],
)
def test_import_skips_command_registration_when_gui_has_no_add_command(self):
_install_fake_freecad_without_gui_commands()
template_authoring = _reload_modules()
self.assertTrue(hasattr(template_authoring, "save_template_as_fcstd"))
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])
def test_save_template_as_fcstd_adds_extension_and_saves_valid_template(self):
_install_fake_freecad()
template_authoring = _reload_modules()
app = sys.modules["FreeCAD"]
doc = FakeDocument()
template_authoring.create_template_terminal(doc, "P1", app.Vector(1, 2, 3))
report = template_authoring.save_template_as_fcstd(doc, "D:/tmp/current-transformer")
self.assertEqual("D:/tmp/current-transformer.FCStd", doc.saved_path)
self.assertEqual("D:/tmp/current-transformer.FCStd", report["path"])
self.assertEqual(1, report["valid_terminals"])
if __name__ == "__main__":
unittest.main()

@ -0,0 +1,162 @@
import importlib
import json
import sys
import tempfile
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.Console = types.SimpleNamespace(
PrintMessage=lambda *args, **kwargs: None,
PrintWarning=lambda *args, **kwargs: None,
PrintError=lambda *args, **kwargs: None,
)
fake_freecad.ActiveDocument = None
fake_freecad.newDocument = lambda name: types.SimpleNamespace(Name=name, Objects=[], getObject=lambda item: None)
sys.modules["FreeCAD"] = fake_freecad
fake_freecadgui = types.ModuleType("FreeCADGui")
fake_freecadgui.SendMsgToActiveView = lambda *args, **kwargs: None
fake_freecadgui.addCommand = lambda *args, **kwargs: None
fake_freecadgui.Selection = types.SimpleNamespace(getSelection=lambda: [])
sys.modules["FreeCADGui"] = fake_freecadgui
fake_importgui = types.ModuleType("ImportGui")
fake_importgui.insert = lambda *args, **kwargs: None
sys.modules["ImportGui"] = fake_importgui
def _reload_exchange_modules():
for name in [
"TerminalObjects",
"TemplateSemantics",
"DeviceImport",
"TerminalImport",
]:
sys.modules.pop(name, None)
template_semantics = importlib.import_module("TemplateSemantics")
terminal_import = importlib.import_module("TerminalImport")
return template_semantics, terminal_import
class TemplateSemanticsRotationTest(unittest.TestCase):
def test_terminal_hint_keeps_source_object_rotation(self):
_install_fake_freecad()
template_semantics, _ = _reload_exchange_modules()
fake_lcs = types.SimpleNamespace(
Name="TerminalA1",
Label="Terminal A1",
TypeId="Part::LocalCoordinateSystem",
Role="Terminal",
Placement=types.SimpleNamespace(
Base=sys.modules["FreeCAD"].Vector(4, 5, 6),
Rotation=sys.modules["FreeCAD"].Rotation(
sys.modules["FreeCAD"].Vector(0, 1, 0),
37.5,
),
),
)
container = types.SimpleNamespace(Group=[fake_lcs])
hints = template_semantics.collect_terminal_hints(container)
self.assertEqual(1, len(hints))
self.assertIn("rotation", hints[0])
self.assertEqual(37.5, hints[0]["rotation"]["angle"])
self.assertEqual(0.0, hints[0]["rotation"]["axis"].x)
self.assertEqual(1.0, hints[0]["rotation"]["axis"].y)
self.assertEqual(0.0, hints[0]["rotation"]["axis"].z)
def test_sidecar_rotation_is_normalized_from_payload(self):
_install_fake_freecad()
template_semantics, _ = _reload_exchange_modules()
with tempfile.TemporaryDirectory() as temp_dir:
model_path = Path(temp_dir) / "Relay.step"
model_path.write_text("", encoding="utf-8")
sidecar_path = Path(temp_dir) / "Relay.qet_template.json"
sidecar_path.write_text(
json.dumps(
{
"terminal_slots": [
{
"name": "A1",
"label": "A1",
"position": {"x": 10, "y": 20, "z": 30},
"rotation": {
"axis": {"x": 0, "y": 0, "z": 1},
"angle": 90,
},
}
]
}
),
encoding="utf-8",
)
slots = template_semantics.load_sidecar_terminal_slots(str(model_path))
self.assertEqual(1, len(slots))
self.assertIn("rotation", slots[0])
self.assertEqual(90.0, slots[0]["rotation"]["angle"])
self.assertEqual(0.0, slots[0]["rotation"]["axis"].x)
self.assertEqual(0.0, slots[0]["rotation"]["axis"].y)
self.assertEqual(1.0, slots[0]["rotation"]["axis"].z)
class TerminalPlacementTest(unittest.TestCase):
def test_slot_placement_uses_rotation_metadata(self):
_install_fake_freecad()
_, terminal_import = _reload_exchange_modules()
slot = {
"base": sys.modules["FreeCAD"].Vector(1, 2, 3),
"rotation": {
"axis": sys.modules["FreeCAD"].Vector(0, 0, 1),
"angle": 45.0,
},
}
placement = terminal_import._slot_placement(slot)
self.assertEqual(1.0, placement.Base.x)
self.assertEqual(2.0, placement.Base.y)
self.assertEqual(3.0, placement.Base.z)
self.assertIsNotNone(placement.Rotation)
self.assertEqual(45.0, placement.Rotation.angle)
self.assertEqual(0.0, placement.Rotation.axis.x)
self.assertEqual(0.0, placement.Rotation.axis.y)
self.assertEqual(1.0, placement.Rotation.axis.z)
if __name__ == "__main__":
unittest.main()
Loading…
Cancel
Save