zhanghao 6 days ago
commit 6b8ffb4036

1
.gitignore vendored

@ -88,3 +88,4 @@ ipch/
/Run-FreeCAD-E.bat
/path-backup-HKCU-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 只负责消费结果。
### 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 接线系统。
### 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. 电气柜与设备装配
除了端子与接线,电气柜场景通常还会遇到另一个基础问题:
@ -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 端子二开实施步骤
### 阶段 1读绑定不做自动路由
@ -647,6 +767,12 @@ LCS 不负责:
- 2D 设备能不能在 3D 找到对应设备
- 2D 端子能不能在 3D 找到对应连接点
补充说明:
- 项目端子创建前,优先读取 FCStd 设备模板中的 `Role="Terminal"` LCS。
- 如果模板没有端子语义,才使用 sidecar 或 bbox fallback。
- 长期目标是让常用设备都具备 FCStd 模板端子,而不是长期依赖 fallback。
### 阶段 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());
restartBox.setIcon(QMessageBox::Warning);
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(
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;
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;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: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: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 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=" 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=" 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;

@ -289,7 +289,7 @@ void AboutDialog::setupLabels()
}
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
@ -328,7 +328,7 @@ void AboutDialog::showCredits()
QString creditsHTML
= 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("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."));
QTextStream stream(&creditsFile);

@ -176,7 +176,7 @@ QByteArray PythonOnlineHelp::fileNotFound() const
"<table width=\"100%\" cellspacing=0 cellpadding=2 border=0 summary=\"heading\">"
"<tr bgcolor=\"#7799ee\">"
"<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>"
"<td align=right valign=bottom>"
"<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\">"
"<td valign=bottom>&nbsp;<br>"
"<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>"
"<td align=right valign=bottom>"
"<font color=\"#ffffff\" face=\"helvetica, arial\">&nbsp;</font></td></tr></table>"

@ -815,13 +815,6 @@ MenuItem* StdWorkbench::setupMenuBar() const
// Help
auto help = new MenuItem(menuBar);
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;
}
@ -879,11 +872,6 @@ ToolBarItem* StdWorkbench::setupToolBars() const
structure->setCommand("Structure");
*structure << "Std_Part" << "Std_Group" << "Std_LinkActions" << "Std_VarSet";
// Help
auto help = new ToolBarItem(root);
help->setCommand("Help");
*help << "Std_WhatsThis";
return root;
}
@ -1027,7 +1015,6 @@ MenuItem* NoneWorkbench::setupMenuBar() const
// Help
auto help = new MenuItem(menuBar);
help->setCommand("&Help");
*help << "Std_OnlineHelp" << "Std_About";
return menuBar;
}
@ -1067,7 +1054,9 @@ MenuItem* TestWorkbench::setupMenuBar() const
MenuItem* menuBar = StdWorkbench::setupMenuBar();
MenuItem* item = menuBar->findItem("&Help");
item->removeItem(item->findItem("Std_WhatsThis"));
if (auto whatsThis = item->findItem("Std_WhatsThis")) {
item->removeItem(whatsThis);
}
// Test commands
auto test = new MenuItem;

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

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

@ -1,5 +1,6 @@
import os
from pathlib import Path
import uuid
import FreeCAD as App
import FreeCADGui as Gui
@ -11,6 +12,10 @@ ROOT_GROUP_NAME = "QETExchangeDevices"
ROOT_GROUP_LABEL = "QET Exchange Devices"
CABINET_MODEL_GROUP_NAME = "QETCabinetModel"
DEVICE_GROUP_PREFIX = "QETDevice_"
TERMINAL_GROUP_PREFIX = "QETTerminals_"
WIRE_GROUP_PREFIX = "QETWires_"
GROUP_KIND_TERMINALS = "Terminals"
GROUP_KIND_WIRES = "Wires"
class DeviceImportError(RuntimeError):
@ -88,6 +93,60 @@ def _ensure_bool_property(obj, prop_name, group_name, description, 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):
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)
@ -116,7 +175,7 @@ def _cabinet_label_text(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)
if root is None:
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",
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
@ -281,6 +348,33 @@ def _ensure_device_group(doc, root_group, element_uuid, instance_id, model_path,
)
if created_now:
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
@ -298,9 +392,19 @@ def _remove_object_tree(doc, obj):
def _clear_group_contents(doc, group):
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)
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):
suffix = Path(model_path).suffix.lower()
return suffix in {
@ -409,7 +513,8 @@ def import_devices_from_payload(payload, scene_path=""):
_append_debug_log("DeviceImport.import_devices_from_payload entered")
doc = _ensure_document(scene_path)
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)
report = {
@ -477,6 +582,13 @@ def import_devices_from_payload(payload, scene_path=""):
continue
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(
doc,
root_group,

@ -7,6 +7,8 @@ import FreeCAD as App
import FreeCADGui as Gui
import DeviceImport
import DevicePreview
import ExchangeWriteBack
import TerminalImport
try:
from PySide6 import QtCore, QtWidgets
@ -24,6 +26,8 @@ STATE_FLAG = "_qet_exchange_bootstrapped"
STATE_PAYLOAD = "_qet_exchange_payload"
STATE_SUMMARY = "_qet_exchange_summary"
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_TREE_FILTER = "_qet_exchange_tree_filter"
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 = [
"QET exchange file loaded successfully.",
"",
@ -593,9 +597,38 @@ def _summary_message(summary, import_report=None):
if 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("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)
@ -641,6 +674,7 @@ def _run_scheduled_device_import(attempt=0):
_show_error("QET Exchange", str(exc))
App.Console.PrintError("[FreeCADExchange] {0}\n".format(exc))
return
except Exception as exc:
_append_debug_log("unexpected device import exception: {0}".format(exc))
_append_debug_log(traceback.format_exc())
@ -680,8 +714,54 @@ def _run_scheduled_device_import(attempt=0):
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)
_show_info("QET Exchange", _summary_message(summary, import_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, terminal_report, writeback_report),
)
_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
from pathlib import Path
import traceback
try:
from PySide6 import QtCore
except ImportError:
try:
from PySide2 import QtCore
except ImportError:
from PySide import QtCore
import FreeCADGui as Gui
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:
local_app_data = os.environ.get("LOCALAPPDATA", "").strip()
local_app_data = os_module.environ.get("LOCALAPPDATA", "").strip()
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:
log_path = os.path.join(str(Path.home()), "AppData", "Local", "QETDeps", "freecad_exchange_bootstrap.log")
os.makedirs(os.path.dirname(log_path), exist_ok=True)
log_path = os_module.path.join(
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:
handle.write(message + "\n")
except Exception:
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()
)
)
QtCore.QTimer.singleShot(0, ExchangeBootstrap.bootstrap_if_requested)
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
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");
if (!helpMenu) {
return;
}
Gui::MenuItem* loadStart = new Gui::MenuItem();
Gui::MenuItem* loadSeparator = new Gui::MenuItem();
loadStart->setCommand("Start_Start");
loadSeparator->setCommand("Separator");
Gui::MenuItem* firstItem = helpMenu->findItem("Std_FreeCADUserHub");
if (firstItem) {
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